From 92b0b4f622e1732fa45be1742648e96ad8ed97ae Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Fri, 15 Jul 2022 13:19:02 -0700 Subject: [PATCH 1/8] Remove files that are programming model related --- azure-pipelines/release-types.yml | 43 -- src/Context.ts | 139 ----- src/FunctionInfo.ts | 70 --- src/converters/BindingConverters.ts | 68 --- src/converters/RpcConverters.ts | 212 -------- src/converters/RpcHttpConverters.ts | 132 ----- src/http/Request.ts | 63 --- src/http/Response.ts | 85 --- src/http/extractHttpUserFromHeaders.ts | 34 -- src/parsers/parseForm.ts | 76 --- src/parsers/parseHeader.ts | 97 ---- src/parsers/parseMultipartForm.ts | 92 ---- test/Context.test.ts | 215 -------- test/FunctionInfo.test.ts | 65 --- test/Types.test.ts | 34 -- test/converters/BindingConverters.test.ts | 197 ------- test/converters/RpcConverters.test.ts | 174 ------ test/converters/RpcHttpConverters.test.ts | 131 ----- test/http/extractHttpUserFromHeaders.test.ts | 86 --- test/parsers/parseForm.test.ts | 241 --------- test/parsers/parseHeader.test.ts | 172 ------ types/.npmignore | 7 - types/LICENSE | 21 - types/README.md | 42 -- types/index.d.ts | 532 ------------------- types/index.test.ts | 206 ------- types/package.json | 16 - types/tsconfig.json | 8 - 28 files changed, 3258 deletions(-) delete mode 100644 azure-pipelines/release-types.yml delete mode 100644 src/Context.ts delete mode 100644 src/FunctionInfo.ts delete mode 100644 src/converters/BindingConverters.ts delete mode 100644 src/converters/RpcConverters.ts delete mode 100644 src/converters/RpcHttpConverters.ts delete mode 100644 src/http/Request.ts delete mode 100644 src/http/Response.ts delete mode 100644 src/http/extractHttpUserFromHeaders.ts delete mode 100644 src/parsers/parseForm.ts delete mode 100644 src/parsers/parseHeader.ts delete mode 100644 src/parsers/parseMultipartForm.ts delete mode 100644 test/Context.test.ts delete mode 100644 test/FunctionInfo.test.ts delete mode 100644 test/Types.test.ts delete mode 100644 test/converters/BindingConverters.test.ts delete mode 100644 test/converters/RpcConverters.test.ts delete mode 100644 test/converters/RpcHttpConverters.test.ts delete mode 100644 test/http/extractHttpUserFromHeaders.test.ts delete mode 100644 test/parsers/parseForm.test.ts delete mode 100644 test/parsers/parseHeader.test.ts delete mode 100644 types/.npmignore delete mode 100644 types/LICENSE delete mode 100644 types/README.md delete mode 100644 types/index.d.ts delete mode 100644 types/index.test.ts delete mode 100644 types/package.json delete mode 100644 types/tsconfig.json diff --git a/azure-pipelines/release-types.yml b/azure-pipelines/release-types.yml deleted file mode 100644 index 87e0dea4..00000000 --- a/azure-pipelines/release-types.yml +++ /dev/null @@ -1,43 +0,0 @@ -parameters: -- name: NpmPublishTag - displayName: 'Tag' - type: string - default: 'latest' -- name: NpmPublishDryRun - displayName: 'Dry Run' - type: boolean - default: true - -trigger: none -pr: none - -resources: - pipelines: - - pipeline: nodeWorkerCI - project: 'Azure Functions' - source: azure-functions-nodejs-worker.build - branch: v3.x - -jobs: -- job: ReleaseTypes - pool: - name: '1ES-Hosted-AzFunc' - demands: - - ImageOverride -equals MMSUbuntu20.04TLS - steps: - - task: NodeTool@0 - displayName: 'Install Node.js' - inputs: - versionSpec: 14.x - - download: nodeWorkerCI - - script: mv *.tgz package.tgz - displayName: 'Rename tgz file' # because the publish command below requires an exact path - workingDirectory: '$(Pipeline.Workspace)/nodeWorkerCI/drop/types' - - task: Npm@1 - displayName: 'npm publish' - inputs: - command: custom - workingDir: '$(Pipeline.Workspace)/nodeWorkerCI/drop/types' - verbose: true - customCommand: 'publish package.tgz --tag ${{ parameters.NpmPublishTag }} --dry-run ${{ lower(parameters.NpmPublishDryRun) }}' - customEndpoint: 'TypeScript Types Publish' \ No newline at end of file diff --git a/src/Context.ts b/src/Context.ts deleted file mode 100644 index 25906669..00000000 --- a/src/Context.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { - BindingDefinition, - Context, - ContextBindingData, - ContextBindings, - ExecutionContext, - Logger, - TraceContext, -} from '@azure/functions'; -import { v4 as uuid } from 'uuid'; -import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { - convertKeysToCamelCase, - getBindingDefinitions, - getNormalizedBindingData, -} from './converters/BindingConverters'; -import { fromRpcTraceContext, fromTypedData } from './converters/RpcConverters'; -import { FunctionInfo } from './FunctionInfo'; -import { Request } from './http/Request'; -import { Response } from './http/Response'; -import EventEmitter = require('events'); -import LogLevel = rpc.RpcLog.Level; - -export function CreateContextAndInputs( - info: FunctionInfo, - request: rpc.IInvocationRequest, - userLogCallback: UserLogCallback -) { - const doneEmitter = new EventEmitter(); - const context = new InvocationContext(info, request, userLogCallback, doneEmitter); - - const bindings: ContextBindings = {}; - const inputs: any[] = []; - let httpInput: Request | undefined; - for (const binding of 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(context.done); - // This is added for backwards compatability with what the host used to send to the worker - context.bindingData.sys = { - methodName: info.name, - utcNow: new Date().toISOString(), - randGuid: uuid(), - }; - // Populate from HTTP request for backwards compatibility if missing - if (!context.bindingData.query) { - context.bindingData.query = Object.assign({}, httpInput.query); - } - if (!context.bindingData.headers) { - context.bindingData.headers = Object.assign({}, httpInput.headers); - } - } - return { - context: context, - inputs: inputs, - doneEmitter, - }; -} - -class InvocationContext implements Context { - invocationId: string; - executionContext: ExecutionContext; - bindings: ContextBindings; - bindingData: ContextBindingData; - traceContext: TraceContext; - bindingDefinitions: BindingDefinition[]; - log: Logger; - req?: Request; - res?: Response; - done: DoneCallback; - - constructor( - info: FunctionInfo, - request: rpc.IInvocationRequest, - userLogCallback: UserLogCallback, - doneEmitter: EventEmitter - ) { - this.invocationId = request.invocationId; - this.traceContext = fromRpcTraceContext(request.traceContext); - const executionContext = { - invocationId: this.invocationId, - functionName: info.name, - functionDirectory: info.directory, - retryContext: request.retryContext, - }; - this.executionContext = executionContext; - this.bindings = {}; - - // Log message that is tied to function invocation - this.log = Object.assign((...args: any[]) => userLogCallback(LogLevel.Information, ...args), { - error: (...args: any[]) => userLogCallback(LogLevel.Error, ...args), - warn: (...args: any[]) => userLogCallback(LogLevel.Warning, ...args), - info: (...args: any[]) => userLogCallback(LogLevel.Information, ...args), - verbose: (...args: any[]) => userLogCallback(LogLevel.Trace, ...args), - }); - - this.bindingData = getNormalizedBindingData(request); - this.bindingDefinitions = getBindingDefinitions(info); - - this.done = (err?: unknown, result?: any) => { - doneEmitter.emit('done', err, result); - }; - } -} - -export interface InvocationResult { - return: any; - bindings: ContextBindings; -} - -export type DoneCallback = (err?: unknown, result?: any) => void; - -export type UserLogCallback = (level: LogLevel, ...args: any[]) => void; - -export interface Dict { - [key: string]: T; -} diff --git a/src/FunctionInfo.ts b/src/FunctionInfo.ts deleted file mode 100644 index 6f6ea4c4..00000000 --- a/src/FunctionInfo.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { toTypedData } from './converters/RpcConverters'; -import { toRpcHttp } from './converters/RpcHttpConverters'; - -const returnBindingKey = '$return'; - -export class FunctionInfo { - name: string; - directory: string; - bindings: { - [key: string]: rpc.IBindingInfo; - }; - outputBindings: { - [key: string]: rpc.IBindingInfo & { converter: (any) => rpc.ITypedData }; - }; - httpOutputName: string; - hasHttpTrigger: boolean; - - constructor(metadata: rpc.IRpcFunctionMetadata) { - this.name = metadata.name; - this.directory = metadata.directory; - this.bindings = {}; - this.outputBindings = {}; - this.httpOutputName = ''; - this.hasHttpTrigger = false; - - if (metadata.bindings) { - const bindings = (this.bindings = metadata.bindings); - - // determine output bindings & assign rpc converter (http has quirks) - Object.keys(bindings) - .filter((name) => bindings[name].direction !== rpc.BindingInfo.Direction.in) - .forEach((name) => { - const type = bindings[name].type; - if (type && type.toLowerCase() === 'http') { - this.httpOutputName = name; - this.outputBindings[name] = Object.assign(bindings[name], { converter: toRpcHttp }); - } else { - this.outputBindings[name] = Object.assign(bindings[name], { converter: toTypedData }); - } - }); - - this.hasHttpTrigger = - Object.keys(bindings).filter((name) => { - const type = bindings[name].type; - return type && type.toLowerCase() === 'httptrigger'; - }).length > 0; - } - } - - /** - * Return output binding details on the special key "$return" output binding - */ - getReturnBinding() { - return this.outputBindings[returnBindingKey]; - } - - getTimerTriggerName(): string | undefined { - for (const name in this.bindings) { - const type = this.bindings[name].type; - if (type && type.toLowerCase() === 'timertrigger') { - return name; - } - } - return; - } -} diff --git a/src/converters/BindingConverters.ts b/src/converters/BindingConverters.ts deleted file mode 100644 index d1cda035..00000000 --- a/src/converters/BindingConverters.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { BindingDefinition, ContextBindingData } from '@azure/functions'; -import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { FunctionInfo } from '../FunctionInfo'; -import { fromTypedData } from './RpcConverters'; - -type BindingDirection = 'in' | 'out' | 'inout' | undefined; - -export function getBindingDefinitions(info: FunctionInfo): BindingDefinition[] { - const bindings = info.bindings; - if (!bindings) { - return []; - } - - return Object.keys(bindings).map((name) => { - return { - name: name, - type: bindings[name].type || '', - direction: getDirectionName(bindings[name].direction), - }; - }); -} - -export function getNormalizedBindingData(request: rpc.IInvocationRequest): ContextBindingData { - const bindingData: ContextBindingData = { - invocationId: request.invocationId, - }; - - // node binding data is camel cased due to language convention - if (request.triggerMetadata) { - Object.assign(bindingData, convertKeysToCamelCase(request.triggerMetadata)); - } - return bindingData; -} - -function getDirectionName(direction: rpc.BindingInfo.Direction | null | undefined): BindingDirection { - const directionName = Object.keys(rpc.BindingInfo.Direction).find( - (k) => rpc.BindingInfo.Direction[k] === direction - ); - return isBindingDirection(directionName) ? (directionName as BindingDirection) : undefined; -} - -function isBindingDirection(input: string | undefined): boolean { - return input == 'in' || input == 'out' || input == 'inout'; -} - -// Recursively convert keys of objects to camel case -export function convertKeysToCamelCase(obj: any) { - const output = {}; - for (const key in obj) { - // Only "undefined" will be replaced with original object property. For example: - //{ string : "0" } -> 0 - //{ string : "false" } -> false - //"test" -> "test" (undefined returned from fromTypedData) - const valueFromDataType = fromTypedData(obj[key]); - const value = valueFromDataType === undefined ? obj[key] : valueFromDataType; - const camelCasedKey = key.charAt(0).toLocaleLowerCase() + key.slice(1); - // If the value is a JSON object (and not array and not http, which is already cased), convert keys to camel case - if (!Array.isArray(value) && typeof value === 'object' && value && value.http == undefined) { - output[camelCasedKey] = convertKeysToCamelCase(value); - } else { - output[camelCasedKey] = value; - } - } - return output; -} diff --git a/src/converters/RpcConverters.ts b/src/converters/RpcConverters.ts deleted file mode 100644 index e405aa21..00000000 --- a/src/converters/RpcConverters.ts +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { TraceContext } from '@azure/functions'; -import { isLong } from 'long'; -import { - AzureFunctionsRpcMessages as rpc, - INullableBool, - INullableDouble, - INullableString, - INullableTimestamp, -} from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { InternalException } from '../utils/InternalException'; - -/** - * Converts 'ITypedData' input from the RPC layer to JavaScript types. - * TypedData can be string, json, or bytes - * @param typedData ITypedData object containing one of a string, json, or bytes property - * @param convertStringToJson Optionally parse the string input type as JSON - */ -export function fromTypedData(typedData?: rpc.ITypedData, convertStringToJson = true) { - typedData = typedData || {}; - let str = typedData.string || typedData.json; - if (str !== undefined) { - if (convertStringToJson) { - try { - if (str != null) { - str = JSON.parse(str); - } - } catch (err) {} - } - return str; - } else if (typedData.bytes) { - return Buffer.from(typedData.bytes); - } else if (typedData.collectionBytes && typedData.collectionBytes.bytes) { - const byteCollection = typedData.collectionBytes.bytes; - return byteCollection.map((element) => Buffer.from(element)); - } else if (typedData.collectionString && typedData.collectionString.string) { - return typedData.collectionString.string; - } else if (typedData.collectionDouble && typedData.collectionDouble.double) { - return typedData.collectionDouble.double; - } else if (typedData.collectionSint64 && typedData.collectionSint64.sint64) { - const longCollection = typedData.collectionSint64.sint64; - return longCollection.map((element) => (isLong(element) ? element.toString() : element)); - } -} - -/** - * Converts 'IRpcTraceContext' input from RPC layer to dictionary of key value pairs. - * @param traceContext IRpcTraceContext object containing the activityId, tracestate and attributes. - */ -export function fromRpcTraceContext(traceContext: rpc.IRpcTraceContext | null | undefined): TraceContext { - if (traceContext) { - return { - traceparent: traceContext.traceParent, - tracestate: traceContext.traceState, - attributes: traceContext.attributes, - }; - } - - return {}; -} - -/** - * Converts JavaScript type data to 'ITypedData' to be sent through the RPC layer - * TypedData can be string, json, or bytes - * @param inputObject A JavaScript object that is a string, Buffer, ArrayBufferView, number, or object. - */ -export function toTypedData(inputObject): rpc.ITypedData { - if (typeof inputObject === 'string') { - return { string: inputObject }; - } else if (Buffer.isBuffer(inputObject)) { - return { bytes: inputObject }; - } else if (ArrayBuffer.isView(inputObject)) { - const bytes = new Uint8Array(inputObject.buffer, inputObject.byteOffset, inputObject.byteLength); - return { bytes: bytes }; - } else if (typeof inputObject === 'number') { - if (Number.isInteger(inputObject)) { - return { int: inputObject }; - } else { - return { double: inputObject }; - } - } else { - return { json: JSON.stringify(inputObject) }; - } -} - -/** - * Converts boolean input to an 'INullableBool' to be sent through the RPC layer. - * Input that is not a boolean but is also not null or undefined logs a function app level warning. - * @param nullable Input to be converted to an INullableBool if it is a valid boolean - * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. - */ -export function toNullableBool(nullable: boolean | undefined, propertyName: string): undefined | INullableBool { - if (typeof nullable === 'boolean') { - return { - value: nullable, - }; - } - - if (nullable != null) { - throw new InternalException( - `A 'boolean' type was expected instead of a '${typeof nullable}' type. Cannot parse value of '${propertyName}'.` - ); - } - - return undefined; -} - -/** - * Converts number or string that parses to a number to an 'INullableDouble' to be sent through the RPC layer. - * Input that is not a valid number but is also not null or undefined logs a function app level warning. - * @param nullable Input to be converted to an INullableDouble if it is a valid number - * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. - */ -export function toNullableDouble( - nullable: number | string | undefined, - propertyName: string -): undefined | INullableDouble { - if (typeof nullable === 'number') { - return { - value: nullable, - }; - } else if (typeof nullable === 'string') { - if (!isNaN(nullable)) { - const parsedNumber = parseFloat(nullable); - return { - value: parsedNumber, - }; - } - } - - if (nullable != null) { - throw new InternalException( - `A 'number' type was expected instead of a '${typeof nullable}' type. Cannot parse value of '${propertyName}'.` - ); - } - - return undefined; -} - -/** - * Converts string input to an 'INullableString' to be sent through the RPC layer. - * Input that is not a string but is also not null or undefined logs a function app level warning. - * @param nullable Input to be converted to an INullableString if it is a valid string - * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. - */ -export function toRpcString(nullable: string | undefined, propertyName: string): string { - if (typeof nullable === 'string') { - return nullable; - } - - if (nullable != null) { - throw new InternalException( - `A 'string' type was expected instead of a '${typeof nullable}' type. Cannot parse value of '${propertyName}'.` - ); - } - - return ''; -} - -/** - * Converts string input to an 'INullableString' to be sent through the RPC layer. - * Input that is not a string but is also not null or undefined logs a function app level warning. - * @param nullable Input to be converted to an INullableString if it is a valid string - * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. - */ -export function toNullableString(nullable: string | undefined, propertyName: string): undefined | INullableString { - if (typeof nullable === 'string') { - return { - value: nullable, - }; - } - - if (nullable != null) { - throw new InternalException( - `A 'string' type was expected instead of a '${typeof nullable}' type. Cannot parse value of '${propertyName}'.` - ); - } - - return undefined; -} - -/** - * Converts Date or number input to an 'INullableTimestamp' to be sent through the RPC layer. - * Input that is not a Date or number but is also not null or undefined logs a function app level warning. - * @param nullable Input to be converted to an INullableTimestamp if it is valid input - * @param propertyName The name of the property that the caller will assign the output to. Used for debugging. - */ -export function toNullableTimestamp( - dateTime: Date | number | undefined, - propertyName: string -): INullableTimestamp | undefined { - if (dateTime != null) { - try { - const timeInMilliseconds = typeof dateTime === 'number' ? dateTime : dateTime.getTime(); - - if (timeInMilliseconds && timeInMilliseconds >= 0) { - return { - value: { - seconds: Math.round(timeInMilliseconds / 1000), - }, - }; - } - } catch { - throw new InternalException( - `A 'number' or 'Date' input was expected instead of a '${typeof dateTime}'. Cannot parse value of '${propertyName}'.` - ); - } - } - return undefined; -} diff --git a/src/converters/RpcHttpConverters.ts b/src/converters/RpcHttpConverters.ts deleted file mode 100644 index ef96dacd..00000000 --- a/src/converters/RpcHttpConverters.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { Cookie } from '@azure/functions'; -import { - AzureFunctionsRpcMessages as rpc, - INullableString, -} from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { Dict } from '../Context'; -import { - fromTypedData, - toNullableBool, - toNullableDouble, - toNullableString, - toNullableTimestamp, - toRpcString, - toTypedData, -} from './RpcConverters'; - -/** - * Converts the provided body from the RPC layer to the appropriate javascript object. - * Body of type 'byte' is a special case and it's converted to it's utf-8 string representation. - * This is to avoid breaking changes in v2. - * @param body The body from the RPC layer. - */ -export function fromRpcHttpBody(body: rpc.ITypedData) { - if (body && body.bytes) { - return (body.bytes).toString(); - } else { - return fromTypedData(body, false); - } -} - -export function fromNullableMapping( - nullableMapping: { [k: string]: INullableString } | null | undefined, - originalMapping?: { [k: string]: string } | null -): Dict { - let converted = {}; - if (nullableMapping && Object.keys(nullableMapping).length > 0) { - for (const key in nullableMapping) { - converted[key] = nullableMapping[key].value || ''; - } - } else if (originalMapping && Object.keys(originalMapping).length > 0) { - converted = >originalMapping; - } - return converted; -} - -/** - * Converts the HTTP 'Response' object to an 'ITypedData' 'http' type to be sent through the RPC layer. - * 'http' types are a special case from other 'ITypedData' types, which come from primitive types. - * @param inputMessage An HTTP response object - */ -export function toRpcHttp(inputMessage): rpc.ITypedData { - // Check if we will fail to find any of these - if (typeof inputMessage !== 'object' || Array.isArray(inputMessage)) { - throw new Error( - "The HTTP response must be an 'object' type that can include properties such as 'body', 'status', and 'headers'. Learn more: https://go.microsoft.com/fwlink/?linkid=2112563" - ); - } - - const httpMessage: rpc.IRpcHttp = inputMessage; - httpMessage.headers = toRpcHttpHeaders(inputMessage.headers); - httpMessage.cookies = toRpcHttpCookieList(inputMessage.cookies || []); - let status = inputMessage.statusCode; - if (typeof inputMessage.status !== 'function') { - status ||= inputMessage.status; - } - httpMessage.statusCode = status && status.toString(); - httpMessage.body = toTypedData(inputMessage.body); - return { http: httpMessage }; -} - -/** - * Convert HTTP headers to a string/string mapping. - * @param inputHeaders - */ -function toRpcHttpHeaders(inputHeaders: rpc.ITypedData) { - const rpcHttpHeaders: { [key: string]: string } = {}; - for (const key in inputHeaders) { - if (inputHeaders[key] != null) { - rpcHttpHeaders[key] = inputHeaders[key].toString(); - } - } - return rpcHttpHeaders; -} - -/** - * Convert HTTP 'Cookie' array to an array of 'IRpcHttpCookie' objects to be sent through the RPC layer - * @param inputCookies array of 'Cookie' objects representing options for the 'Set-Cookie' response header - */ -export function toRpcHttpCookieList(inputCookies: Cookie[]): rpc.IRpcHttpCookie[] { - const rpcCookies: rpc.IRpcHttpCookie[] = []; - inputCookies.forEach((cookie) => { - rpcCookies.push(toRpcHttpCookie(cookie)); - }); - - return rpcCookies; -} - -/** - * From RFC specifications for 'Set-Cookie' response header: https://www.rfc-editor.org/rfc/rfc6265.txt - * @param inputCookie - */ -function toRpcHttpCookie(inputCookie: Cookie): rpc.IRpcHttpCookie { - // Resolve SameSite enum, a one-off - let rpcSameSite: rpc.RpcHttpCookie.SameSite = rpc.RpcHttpCookie.SameSite.None; - if (inputCookie && inputCookie.sameSite) { - const sameSite = inputCookie.sameSite.toLocaleLowerCase(); - if (sameSite === 'lax') { - rpcSameSite = rpc.RpcHttpCookie.SameSite.Lax; - } else if (sameSite === 'strict') { - rpcSameSite = rpc.RpcHttpCookie.SameSite.Strict; - } else if (sameSite === 'none') { - rpcSameSite = rpc.RpcHttpCookie.SameSite.ExplicitNone; - } - } - - const rpcCookie: rpc.IRpcHttpCookie = { - name: inputCookie && toRpcString(inputCookie.name, 'cookie.name'), - value: inputCookie && toRpcString(inputCookie.value, 'cookie.value'), - domain: toNullableString(inputCookie && inputCookie.domain, 'cookie.domain'), - path: toNullableString(inputCookie && inputCookie.path, 'cookie.path'), - expires: toNullableTimestamp(inputCookie && inputCookie.expires, 'cookie.expires'), - secure: toNullableBool(inputCookie && inputCookie.secure, 'cookie.secure'), - httpOnly: toNullableBool(inputCookie && inputCookie.httpOnly, 'cookie.httpOnly'), - sameSite: rpcSameSite, - maxAge: toNullableDouble(inputCookie && inputCookie.maxAge, 'cookie.maxAge'), - }; - - return rpcCookie; -} diff --git a/src/http/Request.ts b/src/http/Request.ts deleted file mode 100644 index 57b9ac07..00000000 --- a/src/http/Request.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { - Form, - HttpMethod, - HttpRequest, - HttpRequestHeaders, - HttpRequestParams, - HttpRequestQuery, - HttpRequestUser, -} from '@azure/functions'; -import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { HeaderName } from '../constants'; -import { fromTypedData } from '../converters/RpcConverters'; -import { fromNullableMapping, fromRpcHttpBody } from '../converters/RpcHttpConverters'; -import { parseForm } from '../parsers/parseForm'; -import { extractHttpUserFromHeaders } from './extractHttpUserFromHeaders'; - -export class Request implements HttpRequest { - method: HttpMethod | null; - url: string; - originalUrl: string; - headers: HttpRequestHeaders; - query: HttpRequestQuery; - params: HttpRequestParams; - body?: any; - rawBody?: any; - - #cachedUser?: HttpRequestUser | null; - - constructor(rpcHttp: rpc.IRpcHttp) { - this.method = rpcHttp.method; - this.url = rpcHttp.url; - this.originalUrl = rpcHttp.url; - this.headers = fromNullableMapping(rpcHttp.nullableHeaders, rpcHttp.headers); - this.query = fromNullableMapping(rpcHttp.nullableQuery, rpcHttp.query); - this.params = fromNullableMapping(rpcHttp.nullableParams, rpcHttp.params); - this.body = fromTypedData(rpcHttp.body); - this.rawBody = fromRpcHttpBody(rpcHttp.body); - } - - get user(): HttpRequestUser | null { - if (this.#cachedUser === undefined) { - this.#cachedUser = extractHttpUserFromHeaders(this.headers); - } - - return this.#cachedUser; - } - - get(field: string): string | undefined { - return this.headers && this.headers[field.toLowerCase()]; - } - - parseFormBody(): Form { - const contentType = this.get(HeaderName.contentType); - if (!contentType) { - throw new Error(`"${HeaderName.contentType}" header must be defined.`); - } else { - return parseForm(this.body, contentType); - } - } -} diff --git a/src/http/Response.ts b/src/http/Response.ts deleted file mode 100644 index 10501649..00000000 --- a/src/http/Response.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { Cookie, HttpResponseFull } from '@azure/functions'; -import { HeaderName, MediaType } from '../constants'; - -export class Response implements HttpResponseFull { - statusCode?: string | number; - headers: { [key: string]: any } = {}; - cookies: Cookie[] = []; - body?: any; - enableContentNegotiation?: boolean; - [key: string]: any; - - // NOTE: This is considered private and people should not be referencing it, but for the sake of backwards compatibility we will avoid using `#` - _done: Function; - - constructor(done: Function) { - this._done = done; - } - - end(body?: any) { - if (body !== undefined) { - this.body = body; - } - this.setContentType(); - this._done(); - return this; - } - - setHeader(field: string, val: any): HttpResponseFull { - this.headers[field.toLowerCase()] = val; - return this; - } - - getHeader(field: string): any { - return this.headers[field.toLowerCase()]; - } - - removeHeader(field: string) { - delete this.headers[field.toLowerCase()]; - return this; - } - - status(statusCode: string | number): HttpResponseFull { - this.statusCode = statusCode; - return this; - } - - sendStatus(statusCode: string | number) { - this.status(statusCode); - // eslint-disable-next-line deprecation/deprecation - return this.end(); - } - - type(type) { - return this.set(HeaderName.contentType, type); - } - - json(body) { - this.type(MediaType.json); - // eslint-disable-next-line deprecation/deprecation - this.send(body); - return; - } - - send = this.end; - header = this.setHeader; - set = this.setHeader; - get = this.getHeader; - - // NOTE: This is considered private and people should not be referencing it, but for the sake of backwards compatibility we will avoid using `#` - setContentType() { - if (this.body !== undefined) { - if (this.get(HeaderName.contentType)) { - // use user defined content type, if exists - return; - } - - if (Buffer.isBuffer(this.body)) { - this.type(MediaType.octetStream); - } - } - } -} diff --git a/src/http/extractHttpUserFromHeaders.ts b/src/http/extractHttpUserFromHeaders.ts deleted file mode 100644 index c35f4da8..00000000 --- a/src/http/extractHttpUserFromHeaders.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { HttpRequestHeaders, HttpRequestUser } from '@azure/functions'; - -export function extractHttpUserFromHeaders(headers: HttpRequestHeaders): HttpRequestUser | null { - let user: HttpRequestUser | null = null; - - if (headers['x-ms-client-principal']) { - const claimsPrincipalData = JSON.parse( - Buffer.from(headers['x-ms-client-principal'], 'base64').toString('utf-8') - ); - - if (claimsPrincipalData['identityProvider']) { - user = { - type: 'StaticWebApps', - id: claimsPrincipalData['userId'], - username: claimsPrincipalData['userDetails'], - identityProvider: claimsPrincipalData['identityProvider'], - claimsPrincipalData, - }; - } else { - user = { - type: 'AppService', - id: headers['x-ms-client-principal-id'], - username: headers['x-ms-client-principal-name'], - identityProvider: headers['x-ms-client-principal-idp'], - claimsPrincipalData, - }; - } - } - - return user; -} diff --git a/src/parsers/parseForm.ts b/src/parsers/parseForm.ts deleted file mode 100644 index 03714d65..00000000 --- a/src/parsers/parseForm.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import * as types from '@azure/functions'; -import { MediaType } from '../constants'; -import { parseContentType } from './parseHeader'; -import { parseMultipartForm } from './parseMultipartForm'; - -/** - * See ./test/parseForm.test.ts for examples - */ -export function parseForm(data: Buffer | string, contentType: string): Form { - const [mediaType, parameters] = parseContentType(contentType); - switch (mediaType.toLowerCase()) { - case MediaType.multipartForm: { - const boundary = parameters.get('boundary'); - const parts = parseMultipartForm(typeof data === 'string' ? Buffer.from(data) : data, boundary); - return new Form(parts); - } - case MediaType.urlEncodedForm: { - const parsed = new URLSearchParams(data.toString()); - const parts: [string, types.FormPart][] = []; - for (const [key, value] of parsed) { - parts.push([key, { value: Buffer.from(value) }]); - } - return new Form(parts); - } - default: - throw new Error( - `Media type "${mediaType}" does not match types supported for form parsing: "${MediaType.multipartForm}", "${MediaType.urlEncodedForm}".` - ); - } -} - -export class Form implements types.Form { - #parts: [string, types.FormPart][]; - constructor(parts: [string, types.FormPart][]) { - this.#parts = parts; - } - - get(name: string): types.FormPart | null { - for (const [key, value] of this.#parts) { - if (key === name) { - return value; - } - } - return null; - } - - getAll(name: string): types.FormPart[] { - const result: types.FormPart[] = []; - for (const [key, value] of this.#parts) { - if (key === name) { - result.push(value); - } - } - return result; - } - - has(name: string): boolean { - for (const [key] of this.#parts) { - if (key === name) { - return true; - } - } - return false; - } - - [Symbol.iterator](): Iterator<[string, types.FormPart]> { - return this.#parts[Symbol.iterator](); - } - - get length(): number { - return this.#parts.length; - } -} diff --git a/src/parsers/parseHeader.ts b/src/parsers/parseHeader.ts deleted file mode 100644 index dbafe8bb..00000000 --- a/src/parsers/parseHeader.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { HeaderName } from '../constants'; - -const space = ' '; -// See "LEXICAL TOKENS" section for definition of ctl chars and quoted string: https://www.w3.org/Protocols/rfc822/3_Lexical.html -const ctlChars = '\\u0000-\\u001F\\u007F'; -const quotedString = '(?:[^"\\\\]|\\\\.)*'; -// General description of content type header, including defintion of tspecials and token https://www.w3.org/Protocols/rfc1341/4_Content-Type.html -const tspecials = '\\(\\)<>@,;:\\\\"\\/\\[\\]\\?\\.='; -const token = `[^${space}${ctlChars}${tspecials}]+`; - -const start = '^\\s*'; // allows leading whitespace -const end = '\\s*(.*)$'; // gets the rest of the string except leading whitespace -const semicolonEnd = `\\s*;?${end}`; // allows optional semicolon and otherwise gets the rest of the string - -/** - * @param data a full header, e.g. "Content-Type: text/html; charset=UTF-8" - * @param headerName the header name, e.g. "Content-Type" - * @returns the header value, e.g. "text/html; charset=UTF-8" or null if not found - */ -export function getHeaderValue(data: string, headerName: string): string | null { - const match = new RegExp(`${start}${headerName}\\s*:${end}`, 'i').exec(data); - if (match) { - return match[1].trim(); - } - return null; -} - -/** - * @param data a content type, e.g. "text/html; charset=UTF-8" - * @returns an array containing the media type (e.g. text/html) and an object with the parameters - */ -export function parseContentType(data: string): [string, HeaderParams] { - const match = new RegExp(`${start}(${token}\\/${token})${semicolonEnd}`, 'i').exec(data); - if (!match) { - throw new Error(`${HeaderName.contentType} must begin with format "type/subtype".`); - } else { - return [match[1], parseHeaderParams(match[2])]; - } -} - -/** - * @param data a content disposition, e.g. "form-data; name=myfile; filename=test.txt" - * @returns an array containing the disposition (e.g. form-data) and an object with the parameters - */ -export function parseContentDisposition(data: string): [string, HeaderParams] { - const match = new RegExp(`${start}(${token})${semicolonEnd}`, 'i').exec(data); - if (!match) { - throw new Error(`${HeaderName.contentDisposition} must begin with disposition type.`); - } else { - return [match[1], parseHeaderParams(match[2])]; - } -} - -function parseHeaderParams(data: string): HeaderParams { - const result = new HeaderParams(); - while (data) { - // try to find an unquoted name=value pair first - const regexp = new RegExp(`${start}(${token})=(${token})${semicolonEnd}`, 'i'); - let match = regexp.exec(data); - // if that didn't work, try to find a quoted name="value" pair instead - if (!match) { - const quotedPartsRegexp = new RegExp(`${start}(${token})="(${quotedString})"${semicolonEnd}`, 'i'); - match = quotedPartsRegexp.exec(data); - } - - if (match) { - result.add(match[1], match[2].replace(/\\"/g, '"')); // un-escape any quotes - data = match[3]; - } else { - break; - } - } - return result; -} - -export class HeaderParams { - #params: { [name: string]: string } = {}; - get(name: string): string { - const result = this.#params[name.toLowerCase()]; - if (result === undefined) { - throw new Error(`Failed to find parameter with name "${name}".`); - } else { - return result; - } - } - - has(name: string): boolean { - return this.#params[name.toLowerCase()] !== undefined; - } - - add(name: string, value: string): void { - this.#params[name.toLowerCase()] = value; - } -} diff --git a/src/parsers/parseMultipartForm.ts b/src/parsers/parseMultipartForm.ts deleted file mode 100644 index d6ab103a..00000000 --- a/src/parsers/parseMultipartForm.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { FormPart } from '@azure/functions'; -import { HeaderName } from '../constants'; -import { getHeaderValue, parseContentDisposition } from './parseHeader'; - -const carriageReturn = Buffer.from('\r')[0]; -const newline = Buffer.from('\n')[0]; - -// multipart/form-data specification https://datatracker.ietf.org/doc/html/rfc7578 -export function parseMultipartForm(chunk: Buffer, boundary: string): [string, FormPart][] { - const result: [string, FormPart][] = []; - let currentName: string | undefined; - let currentPart: FormPart | undefined; - let inHeaders = false; - - const boundaryBuffer = Buffer.from(`--${boundary}`); - const endBoundaryBuffer = Buffer.from(`--${boundary}--`); - - let lineStart = 0; - let lineEnd = 0; - let partValueStart = 0; - let partValueEnd = 0; - - for (let index = 0; index < chunk.length; index++) { - let line: Buffer; - if (chunk[index] === newline) { - lineEnd = chunk[index - 1] === carriageReturn ? index - 1 : index; - line = chunk.slice(lineStart, lineEnd); - lineStart = index + 1; - } else { - continue; - } - - const isBoundary = line.equals(boundaryBuffer); - const isBoundaryEnd = line.equals(endBoundaryBuffer); - if (isBoundary || isBoundaryEnd) { - if (currentPart) { - currentPart.value = chunk.slice(partValueStart, partValueEnd); - } - - if (isBoundaryEnd) { - break; - } - - currentPart = { - value: Buffer.from(''), - }; - inHeaders = true; - } else if (inHeaders) { - if (!currentPart) { - throw new Error(`Expected form data to start with boundary "${boundary}".`); - } - - const lineAsString = line.toString(); - if (!lineAsString) { - // A blank line means we're done with the headers for this part - inHeaders = false; - if (!currentName) { - throw new Error( - `Expected part to have header "${HeaderName.contentDisposition}" with parameter "name".` - ); - } else { - partValueStart = lineStart; - partValueEnd = lineStart; - result.push([currentName, currentPart]); - } - } else { - const contentDisposition = getHeaderValue(lineAsString, HeaderName.contentDisposition); - if (contentDisposition) { - const [, dispositionParts] = parseContentDisposition(contentDisposition); - currentName = dispositionParts.get('name'); - - // filename is optional, even for files - if (dispositionParts.has('fileName')) { - currentPart.fileName = dispositionParts.get('fileName'); - } - } else { - const contentType = getHeaderValue(lineAsString, HeaderName.contentType); - if (contentType) { - currentPart.contentType = contentType; - } - } - } - } else { - partValueEnd = lineEnd; - } - } - - return result; -} diff --git a/test/Context.test.ts b/test/Context.test.ts deleted file mode 100644 index 67392f30..00000000 --- a/test/Context.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import 'mocha'; -import * as sinon from 'sinon'; -import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { CreateContextAndInputs } from '../src/Context'; -import { FunctionInfo } from '../src/FunctionInfo'; - -const timerTriggerInput: rpc.IParameterBinding = { - name: 'myTimer', - data: { - json: JSON.stringify({ - Schedule: {}, - ScheduleStatus: { - Last: '2016-10-04T10:15:00+00:00', - LastUpdated: '2016-10-04T10:16:00+00:00', - Next: '2016-10-04T10:20:00+00:00', - }, - IsPastDue: false, - }), - }, -}; - -describe('Context', () => { - let _logger: any; - - beforeEach(() => { - _logger = sinon.spy(); - }); - - it('camelCases timer trigger input when appropriate', async () => { - const msg: rpc.IInvocationRequest = { - functionId: 'id', - invocationId: '1', - inputData: [timerTriggerInput], - }; - - const info: FunctionInfo = new FunctionInfo({ - name: 'test', - bindings: { - myTimer: { - type: 'timerTrigger', - direction: 0, - dataType: 0, - }, - }, - }); - const workerOutputs = CreateContextAndInputs(info, msg, _logger); - const myTimerWorker = workerOutputs.inputs[0]; - expect(myTimerWorker.schedule).to.be.empty; - expect(myTimerWorker.scheduleStatus.last).to.equal('2016-10-04T10:15:00+00:00'); - expect(myTimerWorker.scheduleStatus.lastUpdated).to.equal('2016-10-04T10:16:00+00:00'); - expect(myTimerWorker.scheduleStatus.next).to.equal('2016-10-04T10:20:00+00:00'); - expect(myTimerWorker.isPastDue).to.equal(false); - }); - - it('Does not add sys to bindingData for non-http', async () => { - const msg: rpc.IInvocationRequest = { - functionId: 'id', - invocationId: '1', - inputData: [timerTriggerInput], - }; - - const info: FunctionInfo = new FunctionInfo({ - name: 'test', - bindings: { - req: { - type: 'http', - direction: 0, - dataType: 1, - }, - }, - }); - - const { context } = CreateContextAndInputs(info, msg, _logger); - expect(context.bindingData.sys).to.be.undefined; - expect(context.bindingData.invocationId).to.equal('1'); - expect(context.invocationId).to.equal('1'); - }); - - it('Adds correct sys properties for bindingData and http', async () => { - const inputDataValue: rpc.IParameterBinding = { - name: 'req', - data: { - http: { - body: { - string: 'blahh', - }, - }, - }, - }; - const msg: rpc.IInvocationRequest = { - functionId: 'id', - invocationId: '1', - inputData: [inputDataValue], - }; - - const info: FunctionInfo = new FunctionInfo({ - name: 'test', - bindings: { - req: { - type: 'http', - direction: 0, - dataType: 1, - }, - }, - }); - - const { context } = CreateContextAndInputs(info, msg, _logger); - const { bindingData } = context; - expect(bindingData.sys.methodName).to.equal('test'); - expect(bindingData.sys.randGuid).to.not.be.undefined; - expect(bindingData.sys.utcNow).to.not.be.undefined; - expect(bindingData.invocationId).to.equal('1'); - expect(context.invocationId).to.equal('1'); - }); - - it('Adds correct header and query properties for bindingData and http using nullable values', async () => { - const inputDataValue: rpc.IParameterBinding = { - name: 'req', - data: { - http: { - body: { - string: 'blahh', - }, - nullableHeaders: { - header1: { - value: 'value1', - }, - header2: { - value: '', - }, - }, - nullableQuery: { - query1: { - value: 'value1', - }, - query2: { - value: undefined, - }, - }, - }, - }, - }; - const msg: rpc.IInvocationRequest = { - functionId: 'id', - invocationId: '1', - inputData: [inputDataValue], - }; - - const info: FunctionInfo = new FunctionInfo({ - name: 'test', - bindings: { - req: { - type: 'http', - direction: 0, - dataType: 1, - }, - }, - }); - - const { context } = CreateContextAndInputs(info, msg, _logger); - const { bindingData } = context; - expect(bindingData.invocationId).to.equal('1'); - expect(bindingData.headers.header1).to.equal('value1'); - expect(bindingData.headers.header2).to.equal(''); - expect(bindingData.query.query1).to.equal('value1'); - expect(bindingData.query.query2).to.equal(''); - expect(context.invocationId).to.equal('1'); - }); - - it('Adds correct header and query properties for bindingData and http using non-nullable values', async () => { - const inputDataValue: rpc.IParameterBinding = { - name: 'req', - data: { - http: { - body: { - string: 'blahh', - }, - headers: { - header1: 'value1', - }, - query: { - query1: 'value1', - }, - }, - }, - }; - const msg: rpc.IInvocationRequest = { - functionId: 'id', - invocationId: '1', - inputData: [inputDataValue], - }; - - const info: FunctionInfo = new FunctionInfo({ - name: 'test', - bindings: { - req: { - type: 'http', - direction: 0, - dataType: 1, - }, - }, - }); - - const { context } = CreateContextAndInputs(info, msg, _logger); - const { bindingData } = context; - expect(bindingData.invocationId).to.equal('1'); - expect(bindingData.headers.header1).to.equal('value1'); - expect(bindingData.query.query1).to.equal('value1'); - expect(context.invocationId).to.equal('1'); - }); -}); diff --git a/test/FunctionInfo.test.ts b/test/FunctionInfo.test.ts deleted file mode 100644 index 2b4af525..00000000 --- a/test/FunctionInfo.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import 'mocha'; -import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { FunctionInfo } from '../src/FunctionInfo'; - -describe('FunctionInfo', () => { - /** NullableBool */ - it('gets $return output binding converter for http', () => { - const metadata: rpc.IRpcFunctionMetadata = { - bindings: { - req: { - type: 'httpTrigger', - direction: 0, - dataType: 1, - }, - $return: { - type: 'http', - direction: 1, - dataType: 1, - }, - }, - }; - - const funcInfo = new FunctionInfo(metadata); - expect(funcInfo.getReturnBinding().converter.name).to.equal('toRpcHttp'); - }); - - it('"hasHttpTrigger" is true for http', () => { - const metadata: rpc.IRpcFunctionMetadata = { - bindings: { - req: { - type: 'httpTrigger', - direction: 0, - dataType: 1, - }, - }, - }; - - const funcInfo = new FunctionInfo(metadata); - expect(funcInfo.getReturnBinding()).to.be.undefined; - expect(funcInfo.hasHttpTrigger).to.be.true; - }); - - it('gets $return output binding converter for TypedData', () => { - const metadata: rpc.IRpcFunctionMetadata = { - bindings: { - input: { - type: 'queue', - direction: 0, - dataType: 1, - }, - $return: { - type: 'queue', - direction: 1, - dataType: 1, - }, - }, - }; - const funcInfo = new FunctionInfo(metadata); - expect(funcInfo.getReturnBinding().converter.name).to.equal('toTypedData'); - }); -}); diff --git a/test/Types.test.ts b/test/Types.test.ts deleted file mode 100644 index e4bd8ecd..00000000 --- a/test/Types.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import * as cp from 'child_process'; -import 'mocha'; -import { ITestCallbackContext } from 'mocha'; -import * as path from 'path'; - -describe('Public TypeScript types', () => { - for (const tsVersion of ['3', '4']) { - it(`builds with TypeScript v${tsVersion}`, async function (this: ITestCallbackContext) { - this.timeout(10 * 1000); - expect(await runTsBuild(tsVersion)).to.equal(0); - }); - } -}); - -async function runTsBuild(tsVersion: string): Promise { - const repoRoot = path.join(__dirname, '..'); - const tscPath = path.join(repoRoot, 'node_modules', `typescript${tsVersion}`, 'bin', 'tsc'); - const projectFile = path.join(repoRoot, 'types', 'tsconfig.json'); - return new Promise((resolve, reject) => { - const cmd = cp.spawn('node', [tscPath, '--project', projectFile]); - cmd.stdout.on('data', function (data) { - console.log(data.toString()); - }); - cmd.stderr.on('data', function (data) { - console.error(data.toString()); - }); - cmd.on('error', reject); - cmd.on('close', resolve); - }); -} diff --git a/test/converters/BindingConverters.test.ts b/test/converters/BindingConverters.test.ts deleted file mode 100644 index d6f80252..00000000 --- a/test/converters/BindingConverters.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import { fromString } from 'long'; -import 'mocha'; -import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { getBindingDefinitions, getNormalizedBindingData } from '../../src/converters/BindingConverters'; -import { fromTypedData } from '../../src/converters/RpcConverters'; -import { toRpcHttp } from '../../src/converters/RpcHttpConverters'; -import { FunctionInfo } from '../../src/FunctionInfo'; - -describe('Binding Converters', () => { - it('normalizes binding trigger metadata for HTTP', () => { - const mockRequest: rpc.ITypedData = toRpcHttp({ url: 'https://mock' }); - const triggerDataMock: { [k: string]: rpc.ITypedData } = { - Headers: { - json: JSON.stringify({ Connection: 'Keep-Alive' }), - }, - Req: mockRequest, - Sys: { - json: JSON.stringify({ MethodName: 'test-js', UtcNow: '2018', RandGuid: '3212' }), - }, - $request: { - string: 'Https://mock/', - }, - falsyZero: { - string: '0', - }, - falsyFalse: { - string: 'false', - }, - falsyNull: { - string: 'null', - }, - falsyEmptyString: { - string: '', - }, - falsyUndefined: { - string: undefined, - }, - }; - - const request: rpc.IInvocationRequest = { - triggerMetadata: triggerDataMock, - invocationId: '12341', - }; - - const bindingData = getNormalizedBindingData(request); - // Verify conversion to camelCase - expect(bindingData.invocationId).to.equal('12341'); - expect(bindingData.headers.connection).to.equal('Keep-Alive'); - expect(bindingData.req.http.url).to.equal('https://mock'); - expect(bindingData.sys.methodName).to.equal('test-js'); - expect(bindingData.sys.utcNow).to.equal('2018'); - expect(bindingData.sys.randGuid).to.equal('3212'); - expect(bindingData.$request).to.equal('Https://mock/'); - expect(bindingData.falsyZero).to.equal(0); - expect(bindingData.falsyFalse).to.equal(false); - expect(bindingData.falsyNull).to.equal(null); - expect(bindingData.falsyEmptyString.string).to.equal(''); - expect(bindingData.falsyUndefined.string).to.equal(undefined); - // Verify accessing original keys is undefined - expect(bindingData.Sys).to.be.undefined; - expect(bindingData.sys.UtcNow).to.be.undefined; - }); - - it('normalizes binding trigger metadata containing arrays', () => { - const triggerDataMock: { [k: string]: rpc.ITypedData } = { - EnqueuedMessages: { - json: JSON.stringify(['Hello 1', 'Hello 2']), - }, - SequenceNumberArray: { - json: JSON.stringify([1, 2]), - }, - Properties: { - json: JSON.stringify({ Greetings: ['Hola', 'Salut', 'Konichiwa'], SequenceNumber: [1, 2, 3] }), - }, - Sys: { - json: JSON.stringify({ MethodName: 'test-js', UtcNow: '2018', RandGuid: '3212' }), - }, - }; - const request: rpc.IInvocationRequest = { - triggerMetadata: triggerDataMock, - invocationId: '12341', - }; - - const bindingData = getNormalizedBindingData(request); - // Verify conversion to camelCase - expect(bindingData.invocationId).to.equal('12341'); - expect(Array.isArray(bindingData.enqueuedMessages)).to.be.true; - expect(bindingData.enqueuedMessages.length).to.equal(2); - expect(bindingData.enqueuedMessages[1]).to.equal('Hello 2'); - expect(Array.isArray(bindingData.sequenceNumberArray)).to.be.true; - expect(bindingData.sequenceNumberArray.length).to.equal(2); - expect(bindingData.sequenceNumberArray[0]).to.equal(1); - expect(bindingData.sys.methodName).to.equal('test-js'); - expect(bindingData.sys.utcNow).to.equal('2018'); - expect(bindingData.sys.randGuid).to.equal('3212'); - // Verify that nested arrays are converted correctly - const properties = bindingData.properties; - expect(Array.isArray(properties.greetings)).to.be.true; - expect(properties.greetings.length).to.equal(3); - expect(properties.greetings[1]).to.equal('Salut'); - expect(Array.isArray(properties.sequenceNumber)).to.be.true; - expect(properties.sequenceNumber.length).to.equal(3); - expect(properties.sequenceNumber[0]).to.equal(1); - // Verify accessing original keys is undefined - expect(bindingData.Sys).to.be.undefined; - expect(bindingData.sys.UtcNow).to.be.undefined; - }); - - it('catologues binding definitions', () => { - const functionMetaData: rpc.IRpcFunctionMetadata = { - name: 'MyFunction', - directory: '.', - scriptFile: 'index.js', - bindings: { - req: { - type: 'httpTrigger', - direction: rpc.BindingInfo.Direction.in, - }, - res: { - type: 'http', - direction: rpc.BindingInfo.Direction.out, - }, - firstQueueOutput: { - type: 'queue', - direction: rpc.BindingInfo.Direction.out, - }, - noDirection: { - type: 'queue', - }, - }, - }; - - const functionInfo: FunctionInfo = new FunctionInfo(functionMetaData); - - const bindingDefinitions = getBindingDefinitions(functionInfo); - // Verify conversion to camelCase - expect(bindingDefinitions.length).to.equal(4); - expect(bindingDefinitions[0].name).to.equal('req'); - expect(bindingDefinitions[0].direction).to.equal('in'); - expect(bindingDefinitions[0].type).to.equal('httpTrigger'); - expect(bindingDefinitions[1].name).to.equal('res'); - expect(bindingDefinitions[1].direction).to.equal('out'); - expect(bindingDefinitions[1].type).to.equal('http'); - expect(bindingDefinitions[2].name).to.equal('firstQueueOutput'); - expect(bindingDefinitions[2].direction).to.equal('out'); - expect(bindingDefinitions[2].type).to.equal('queue'); - expect(bindingDefinitions[3].name).to.equal('noDirection'); - expect(bindingDefinitions[3].direction).to.be.undefined; - expect(bindingDefinitions[3].type).to.equal('queue'); - }); - - it('deserializes string data with fromTypedData', () => { - const data = fromTypedData({ string: 'foo' }); - expect(data).to.equal('foo'); - }); - - it('deserializes json data with fromTypedData', () => { - const data = fromTypedData({ json: '{ "foo": "bar" }' }); - expect(data && data['foo']).to.equal('bar'); - }); - - it('deserializes byte data with fromTypedData', () => { - const buffer = Buffer.from('hello'); - const data = fromTypedData({ bytes: buffer }); - expect(data && data['buffer']).to.equal(buffer.buffer); - }); - - it('deserializes collectionBytes data with fromTypedData', () => { - const fooBuffer = Buffer.from('foo'); - const barBuffer = Buffer.from('bar'); - const data = fromTypedData({ collectionBytes: { bytes: [fooBuffer, barBuffer] } }); - expect(data && data[0] && data[0]['buffer']).to.equal(fooBuffer.buffer); - expect(data && data[1] && data[1]['buffer']).to.equal(barBuffer.buffer); - }); - - it('deserializes collectionString data with fromTypedData', () => { - const data = fromTypedData({ collectionString: { string: ['foo', 'bar'] } }); - expect(data && data[0]).to.equal('foo'); - expect(data && data[1]).to.equal('bar'); - }); - - it('deserializes collectionDouble data with fromTypedData', () => { - const data = fromTypedData({ collectionDouble: { double: [1.1, 2.2] } }); - expect(data && data[0]).to.equal(1.1); - expect(data && data[1]).to.equal(2.2); - }); - - it('deserializes collectionSint64 data with fromTypedData', () => { - const data = fromTypedData({ collectionSint64: { sint64: [123, fromString('9007199254740992')] } }); - expect(data && data[0]).to.equal(123); - expect(data && data[1]).to.equal('9007199254740992'); - }); -}); diff --git a/test/converters/RpcConverters.test.ts b/test/converters/RpcConverters.test.ts deleted file mode 100644 index bd31a0cd..00000000 --- a/test/converters/RpcConverters.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import 'mocha'; -import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { - fromRpcTraceContext, - toNullableBool, - toNullableDouble, - toNullableString, - toNullableTimestamp, -} from '../../src/converters/RpcConverters'; - -describe('Rpc Converters', () => { - /** NullableBool */ - it('converts true to NullableBool', () => { - const nullable = toNullableBool(true, 'test'); - expect(nullable && nullable.value).to.equal(true); - }); - - it('converts false to NullableBool', () => { - const nullable = toNullableBool(false, 'test'); - expect(nullable && nullable.value).to.equal(false); - }); - - it('throws and does not converts string to NullableBool', () => { - expect(() => { - toNullableBool('true', 'test'); - }).to.throw("A 'boolean' type was expected instead of a 'string' type. Cannot parse value of 'test'."); - }); - - it('Converts IRpcTraceContext to tracecontext', () => { - const traceparentvalue = 'tracep'; - const tracestatevalue = 'traces'; - const attributesvalue = { traceparent: 'traceparent', tracestate: 'tracestate' }; - - const input = { - traceParent: traceparentvalue, - traceState: tracestatevalue, - attributes: attributesvalue, - }; - - const traceContext = fromRpcTraceContext(input); - - expect(traceparentvalue).to.equal(traceContext.traceparent); - expect(tracestatevalue).to.equal(traceContext.tracestate); - expect(attributesvalue).to.equal(traceContext.attributes); - }); - - it('Converts null traceContext to empty values', () => { - const traceContext = fromRpcTraceContext(null); - expect(traceContext.traceparent).to.be.undefined; - expect(traceContext.tracestate).to.be.undefined; - expect(traceContext.attributes).to.be.undefined; - }); - - it('Converts undefined traceContext to empty values', () => { - const traceContext = fromRpcTraceContext(undefined); - expect(traceContext.traceparent).to.be.undefined; - expect(traceContext.tracestate).to.be.undefined; - expect(traceContext.attributes).to.be.undefined; - }); - - it('does not converts null to NullableBool', () => { - const nullable = toNullableBool(null, 'test'); - expect(nullable && nullable.value).to.be.undefined; - }); - - /** NullableString */ - it('converts string to NullableString', () => { - const input = 'hello'; - const nullable = toNullableString(input, 'test'); - expect(nullable && nullable.value).to.equal(input); - }); - - it('converts empty string to NullableString', () => { - const input = ''; - const nullable = toNullableString(input, 'test'); - expect(nullable && nullable.value).to.equal(input); - }); - - it('throws and does not convert number to NullableString', () => { - expect(() => { - toNullableString(123, 'test'); - }).to.throw("A 'string' type was expected instead of a 'number' type. Cannot parse value of 'test'."); - }); - - it('does not convert null to NullableString', () => { - const nullable = toNullableString(null, 'test'); - expect(nullable && nullable.value).to.be.undefined; - }); - - /** NullableDouble */ - it('converts number to NullableDouble', () => { - const input = 1234567; - const nullable = toNullableDouble(input, 'test'); - expect(nullable && nullable.value).to.equal(input); - }); - - it('converts 0 to NullableDouble', () => { - const input = 0; - const nullable = toNullableDouble(input, 'test'); - expect(nullable && nullable.value).to.equal(input); - }); - - it('converts negative number to NullableDouble', () => { - const input = -11234567; - const nullable = toNullableDouble(input, 'test'); - expect(nullable && nullable.value).to.equal(input); - }); - - it('converts numeric string to NullableDouble', () => { - const input = '1234567'; - const nullable = toNullableDouble(input, 'test'); - expect(nullable && nullable.value).to.equal(1234567); - }); - - it('converts float string to NullableDouble', () => { - const input = '1234567.002'; - const nullable = toNullableDouble(input, 'test'); - expect(nullable && nullable.value).to.equal(1234567.002); - }); - - it('throws and does not convert non-number string to NullableDouble', () => { - expect(() => { - toNullableDouble('123hellohello!!111', 'test'); - }).to.throw("A 'number' type was expected instead of a 'string' type. Cannot parse value of 'test'."); - }); - - it('does not convert undefined to NullableDouble', () => { - const nullable = toNullableDouble(undefined, 'test'); - expect(nullable && nullable.value).to.be.undefined; - }); - - /** NullableTimestamp */ - it('converts Date to NullableTimestamp', () => { - const input = new Date('1/2/2014'); - const nullable = toNullableTimestamp(input, 'test'); - const secondInput = Math.round((input).getTime() / 1000); - expect(nullable && nullable.value && nullable.value.seconds).to.equal(secondInput); - }); - - it('converts Date.now to NullableTimestamp', () => { - const input = Date.now(); - const nullable = toNullableTimestamp(input, 'test'); - const secondInput = Math.round(input / 1000); - expect(nullable && nullable.value && nullable.value.seconds).to.equal(secondInput); - }); - - it('converts milliseconds to NullableTimestamp', () => { - const input = Date.now(); - const nullable = toNullableTimestamp(input, 'test'); - const secondInput = Math.round(input / 1000); - expect(nullable && nullable.value && nullable.value.seconds).to.equal(secondInput); - }); - - it('does not convert string to NullableTimestamp', () => { - expect(() => { - toNullableTimestamp('1/2/3 2014', 'test'); - }).to.throw("A 'number' or 'Date' input was expected instead of a 'string'. Cannot parse value of 'test'."); - }); - - it('does not convert object to NullableTimestamp', () => { - expect(() => { - toNullableTimestamp({ time: 100 }, 'test'); - }).to.throw("A 'number' or 'Date' input was expected instead of a 'object'. Cannot parse value of 'test'."); - }); - - it('does not convert undefined to NullableTimestamp', () => { - const nullable = toNullableTimestamp(undefined, 'test'); - expect(nullable && nullable.value).to.be.undefined; - }); -}); diff --git a/test/converters/RpcHttpConverters.test.ts b/test/converters/RpcHttpConverters.test.ts deleted file mode 100644 index d0441035..00000000 --- a/test/converters/RpcHttpConverters.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { Cookie } from '@azure/functions'; -import { expect } from 'chai'; -import 'mocha'; -import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { toRpcHttp, toRpcHttpCookieList } from '../../src/converters/RpcHttpConverters'; - -describe('Rpc Converters', () => { - /** NullableBool */ - it('converts http cookies', () => { - const cookieInputs = [ - { - name: 'mycookie', - value: 'myvalue', - maxAge: 200000, - }, - { - name: 'mycookie2', - value: 'myvalue2', - path: '/', - maxAge: '200000', - }, - { - name: 'mycookie3-expires', - value: 'myvalue3-expires', - expires: new Date('December 17, 1995 03:24:00 PST'), - }, - ]; - - const rpcCookies = toRpcHttpCookieList(cookieInputs); - expect(rpcCookies[0].name).to.equal('mycookie'); - expect(rpcCookies[0].value).to.equal('myvalue'); - expect((rpcCookies[0].maxAge).value).to.equal(200000); - - expect(rpcCookies[1].name).to.equal('mycookie2'); - expect(rpcCookies[1].value).to.equal('myvalue2'); - expect((rpcCookies[1].path).value).to.equal('/'); - expect((rpcCookies[1].maxAge).value).to.equal(200000); - - expect(rpcCookies[2].name).to.equal('mycookie3-expires'); - expect(rpcCookies[2].value).to.equal('myvalue3-expires'); - expect((rpcCookies[2].expires).value.seconds).to.equal(819199440); - }); - - it('converts http cookie SameSite', () => { - const cookieInputs: Cookie[] = [ - { - name: 'none-cookie', - value: 'myvalue', - sameSite: 'None', - }, - { - name: 'lax-cookie', - value: 'myvalue', - sameSite: 'Lax', - }, - { - name: 'strict-cookie', - value: 'myvalue', - sameSite: 'Strict', - }, - { - name: 'default-cookie', - value: 'myvalue', - }, - ]; - - const rpcCookies = toRpcHttpCookieList(cookieInputs); - expect(rpcCookies[0].name).to.equal('none-cookie'); - expect(rpcCookies[0].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.ExplicitNone); - - expect(rpcCookies[1].name).to.equal('lax-cookie'); - expect(rpcCookies[1].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.Lax); - - expect(rpcCookies[2].name).to.equal('strict-cookie'); - expect(rpcCookies[2].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.Strict); - - expect(rpcCookies[3].name).to.equal('default-cookie'); - expect(rpcCookies[3].sameSite).to.equal(rpc.RpcHttpCookie.SameSite.None); - }); - - it('throws on invalid input', () => { - expect(() => { - const cookieInputs = [ - { - name: 123, - value: 'myvalue', - maxAge: 200000, - }, - { - name: 'mycookie2', - value: 'myvalue2', - path: '/', - maxAge: '200000', - }, - { - name: 'mycookie3-expires', - value: 'myvalue3-expires', - expires: new Date('December 17, 1995 03:24:00'), - }, - { - name: 'mycookie3-expires', - value: 'myvalue3-expires', - expires: new Date(''), - }, - ]; - - toRpcHttpCookieList(cookieInputs); - }).to.throw(''); - }); - - it('throws on array as http response', () => { - expect(() => { - const response = ['one', 2, '3']; - toRpcHttp(response); - }).to.throw( - "The HTTP response must be an 'object' type that can include properties such as 'body', 'status', and 'headers'. Learn more: https://go.microsoft.com/fwlink/?linkid=2112563" - ); - }); - - it('throws on string as http response', () => { - expect(() => { - const response = 'My output string'; - toRpcHttp(response); - }).to.throw( - "The HTTP response must be an 'object' type that can include properties such as 'body', 'status', and 'headers'. Learn more: https://go.microsoft.com/fwlink/?linkid=2112563" - ); - }); -}); diff --git a/test/http/extractHttpUserFromHeaders.test.ts b/test/http/extractHttpUserFromHeaders.test.ts deleted file mode 100644 index 4e76ae72..00000000 --- a/test/http/extractHttpUserFromHeaders.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { HttpRequestHeaders, HttpRequestUser } from '@azure/functions'; -import { expect } from 'chai'; -import 'mocha'; -import { v4 as uuid } from 'uuid'; -import { extractHttpUserFromHeaders } from '../../src/http/extractHttpUserFromHeaders'; - -describe('Extract Http User Claims Principal from Headers', () => { - it('Correctly parses AppService headers', () => { - const username = 'test@example.com'; - const id: string = uuid(); - const provider = 'aad'; - const claimsPrincipalData = { - auth_typ: provider, - claims: [ - { - typ: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', - val: username, - }, - { typ: 'name', val: 'Example Test' }, - { typ: 'nonce', val: '54b39eaa5596466f9336b9369e91d95e_20211117004911' }, - { - typ: 'http://schemas.microsoft.com/identity/claims/objectidentifier', - val: id, - }, - { typ: 'preferred_username', val: username }, - { typ: 'ver', val: '2.0' }, - ], - name_typ: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', - role_typ: 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role', - }; - - const headers: HttpRequestHeaders = { - 'x-ms-client-principal-name': username, - 'x-ms-client-principal-id': id, - 'x-ms-client-principal-idp': provider, - 'x-ms-client-principal': Buffer.from(JSON.stringify(claimsPrincipalData)).toString('base64'), - }; - - const user: HttpRequestUser | null = extractHttpUserFromHeaders(headers); - - expect(user).to.not.be.null; - expect(user?.type).to.equal('AppService'); - expect(user?.id).to.equal(id); - expect(user?.username).to.equal(username); - expect(user?.identityProvider).to.equal(provider); - expect(user?.claimsPrincipalData).to.deep.equal(claimsPrincipalData); - }); - - it('Correctly parses StaticWebApps headers', () => { - const id = uuid(); - const username = 'test@example.com'; - const provider = 'aad'; - const claimsPrinicipalData = { - userId: id, - userRoles: ['anonymous', 'authenticated'], - identityProvider: provider, - userDetails: username, - }; - - const headers: HttpRequestHeaders = { - 'x-ms-client-principal': Buffer.from(JSON.stringify(claimsPrinicipalData)).toString('base64'), - }; - - const user: HttpRequestUser | null = extractHttpUserFromHeaders(headers); - - expect(user).to.not.be.null; - expect(user?.type).to.equal('StaticWebApps'); - expect(user?.id).to.equal(id); - expect(user?.username).to.equal(username); - expect(user?.identityProvider).to.equal(provider); - expect(user?.claimsPrincipalData).to.deep.equal(claimsPrinicipalData); - }); - - it('Correctly returns null on missing header data', () => { - const headers: HttpRequestHeaders = { - key1: 'val1', - }; - - const user: HttpRequestUser | null = extractHttpUserFromHeaders(headers); - - expect(user).to.be.null; - }); -}); diff --git a/test/parsers/parseForm.test.ts b/test/parsers/parseForm.test.ts deleted file mode 100644 index 7c28aa97..00000000 --- a/test/parsers/parseForm.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import 'mocha'; -import { parseForm } from '../../src/parsers/parseForm'; - -describe('parseForm', () => { - describe('multipart', () => { - const contentType = 'multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv'; - - it('hello world', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="name" - -Azure Functions -------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="greeting" - -Hello -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(2); - - expect(parsedForm.has('name')).to.equal(true); - expect(parsedForm.get('name')).to.deep.equal({ - value: Buffer.from('Azure Functions'), - }); - - expect(parsedForm.has('greeting')).to.equal(true); - expect(parsedForm.get('greeting')).to.deep.equal({ - value: Buffer.from('Hello'), - }); - }); - - it('file', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="myfile"; filename="test.txt" -Content-Type: text/plain - -hello -world -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(1); - expect(parsedForm.has('myfile')).to.equal(true); - expect(parsedForm.get('myfile')).to.deep.equal({ - value: Buffer.from(`hello -world`), - fileName: 'test.txt', - contentType: 'text/plain', - }); - }); - - it('empty field', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="emptyfield" - -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(1); - expect(parsedForm.has('emptyfield')).to.equal(true); - expect(parsedForm.get('emptyfield')).to.deep.equal({ - value: Buffer.from(''), - }); - }); - - it('empty file', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="myemptyfile"; filename="emptyTest.txt" -Content-Type: text/plain - -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(1); - expect(parsedForm.has('myemptyfile')).to.equal(true); - expect(parsedForm.get('myemptyfile')).to.deep.equal({ - value: Buffer.from(''), - fileName: 'emptyTest.txt', - contentType: 'text/plain', - }); - }); - - it('empty form', async () => { - const data = Buffer.from(''); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(0); - }); - - it('duplicate parts', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="dupeField" - -value1 -------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="dupeField" - -value2 -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(2); - expect(parsedForm.has('dupeField')).to.equal(true); - expect(parsedForm.get('dupeField')).to.deep.equal({ - value: Buffer.from('value1'), - }); - - expect(parsedForm.getAll('dupeField')).to.deep.equal([ - { - value: Buffer.from('value1'), - }, - { - value: Buffer.from('value2'), - }, - ]); - }); - - it('weird casing and whitespace', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv - CoNtent-DispOsItion : forM-dAta ; naMe="wEirdCasing" ; fILename="tEsT.txt" - COnteNt-tYpe: texT/plaIn - - hello -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - const parsedForm = parseForm(data, contentType); - expect(parsedForm.has('wEirdCasing')).to.equal(true); - expect(parsedForm.get('wEirdCasing')).to.deep.equal({ - value: Buffer.from(' hello '), - fileName: 'tEsT.txt', - contentType: 'texT/plaIn', - }); - }); - - it('\\n', async () => { - const data = Buffer.from( - `------WebKitFormBoundaryeJGMO2YP65ZZXRmv\nContent-Disposition: form-data; name="hello"\n\nworld\n------WebKitFormBoundaryeJGMO2YP65ZZXRmv--\n` - ); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.has('hello')).to.equal(true); - expect(parsedForm.get('hello')).to.deep.equal({ - value: Buffer.from('world'), - }); - }); - - it('\\r\\n', async () => { - const data = Buffer.from( - `------WebKitFormBoundaryeJGMO2YP65ZZXRmv\r\nContent-Disposition: form-data; name="hello"\r\n\r\nworld\r\n------WebKitFormBoundaryeJGMO2YP65ZZXRmv--\r\n` - ); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.has('hello')).to.equal(true); - expect(parsedForm.get('hello')).to.deep.equal({ - value: Buffer.from('world'), - }); - }); - - it('html file with charset', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; name="htmlfile"; filename="test.html" -Content-Type: text/html; charset=UTF-8 - -

Hi

-------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - const parsedForm = parseForm(data, contentType); - expect(parsedForm.length).to.equal(1); - expect(parsedForm.has('htmlfile')).to.equal(true); - expect(parsedForm.get('htmlfile')).to.deep.equal({ - value: Buffer.from('

Hi

'), - fileName: 'test.html', - contentType: 'text/html; charset=UTF-8', - }); - }); - - it('Missing content-disposition', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-oops: form-data; name="name" - -Azure Functions -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - expect(() => parseForm(data, contentType)).to.throw(/expected.*content-disposition/i); - }); - - it('Missing content-disposition name', async () => { - const data = Buffer.from(`------WebKitFormBoundaryeJGMO2YP65ZZXRmv -Content-Disposition: form-data; nameOops="name" - -Azure Functions -------WebKitFormBoundaryeJGMO2YP65ZZXRmv-- -`); - - expect(() => parseForm(data, contentType)).to.throw(/failed to find parameter/i); - }); - }); - - describe('urlencoded', () => { - const contentType = 'application/x-www-form-urlencoded'; - - it('hello world', async () => { - const data = 'name=Azure+Functions&greeting=Hello'; - - const parsedForm = parseForm(data, contentType); - - expect(parsedForm.has('name')).to.equal(true); - expect(parsedForm.get('name')).to.deep.equal({ - value: Buffer.from('Azure Functions'), - }); - - expect(parsedForm.has('greeting')).to.equal(true); - expect(parsedForm.get('greeting')).to.deep.equal({ - value: Buffer.from('Hello'), - }); - }); - }); - - it('Unsupported content type', async () => { - const expectedError = /media type.*does not match/i; - expect(() => parseForm('', 'application/octet-stream')).to.throw(expectedError); - expect(() => parseForm('', 'application/json')).to.throw(expectedError); - expect(() => parseForm('', 'text/plain')).to.throw(expectedError); - }); - - it('Invalid content type', async () => { - expect(() => parseForm('', 'invalid')).to.throw(/content-type.*format/i); - }); -}); diff --git a/test/parsers/parseHeader.test.ts b/test/parsers/parseHeader.test.ts deleted file mode 100644 index a96ed38a..00000000 --- a/test/parsers/parseHeader.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -import { expect } from 'chai'; -import 'mocha'; -import { getHeaderValue, parseContentDisposition, parseContentType } from '../../src/parsers/parseHeader'; - -describe('getHeaderValue', () => { - it('normal', async () => { - expect(getHeaderValue('content-type: text/plain', 'content-type')).to.equal('text/plain'); - }); - - it('weird casing', async () => { - expect(getHeaderValue('ConTent-TypE: text/plain', 'cOntent-type')).to.equal('text/plain'); - }); - - it('weird spacing', async () => { - expect(getHeaderValue(' Content-Type : text/plain ', 'content-type')).to.equal('text/plain'); - }); - - it('with parameter', async () => { - expect(getHeaderValue('Content-Type: text/html; charset=UTF-8', 'content-type')).to.equal( - 'text/html; charset=UTF-8' - ); - }); - - it('with parameter and weird spacing', async () => { - expect(getHeaderValue(' Content-Type: text/html; charset=UTF-8 ', 'content-type')).to.equal( - 'text/html; charset=UTF-8' - ); - }); - - it('missing', async () => { - expect(getHeaderValue('oops: text/plain', 'content-type')).to.equal(null); - }); - - it('invalid', async () => { - expect(getHeaderValue('invalid', 'content-type')).to.equal(null); - }); -}); - -describe('parseContentType', () => { - describe('getMediaType', () => { - function getMediaType(data: string): string { - return parseContentType(data)[0]; - } - - it('json', async () => { - expect(getMediaType('application/json')).to.equal('application/json'); - }); - - it('form', async () => { - expect(getMediaType('multipart/form-data')).to.equal('multipart/form-data'); - }); - - it('with semicolon', async () => { - expect(getMediaType('multipart/form-data;')).to.equal('multipart/form-data'); - }); - - it('with param', async () => { - expect(getMediaType('multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv')).to.equal( - 'multipart/form-data' - ); - }); - - it('with multiple params', async () => { - expect( - getMediaType('multipart/form-data; test=abc; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv') - ).to.equal('multipart/form-data'); - }); - - it('weird whitespace', async () => { - expect(getMediaType(' multipart/form-data; ')).to.equal('multipart/form-data'); - }); - - it('weird whitespace with param', async () => { - expect( - getMediaType(' multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv ') - ).to.equal('multipart/form-data'); - }); - - it('invalid', async () => { - expect(() => getMediaType('invalid')).to.throw(/content-type.*format/i); - }); - }); - - describe('getFormBoundary', () => { - function getFormBoundary(data: string): string { - return parseContentType(data)[1].get('boundary'); - } - - it('normal', async () => { - expect(getFormBoundary('multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv')).to.equal( - '----WebKitFormBoundaryeJGMO2YP65ZZXRmv' - ); - }); - - it('semicolon at the end', async () => { - expect(getFormBoundary('multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv;')).to.equal( - '----WebKitFormBoundaryeJGMO2YP65ZZXRmv' - ); - }); - - it('different casing', async () => { - expect(getFormBoundary('multipart/form-data; bOunDary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv;')).to.equal( - '----WebKitFormBoundaryeJGMO2YP65ZZXRmv' - ); - }); - - it('weird whitespace', async () => { - expect( - getFormBoundary(' multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv; ') - ).to.equal('----WebKitFormBoundaryeJGMO2YP65ZZXRmv'); - }); - - it('quotes', async () => { - expect(getFormBoundary('multipart/form-data; boundary="----WebKitFormBoundaryeJGMO2YP65ZZXRmv"')).to.equal( - '----WebKitFormBoundaryeJGMO2YP65ZZXRmv' - ); - }); - - it('escaped quotes', async () => { - expect( - getFormBoundary('multipart/form-data; boundary="----WebKitFormBounda\\"rye\\"JGMO2YP65ZZXRmv"') - ).to.equal('----WebKitFormBounda"rye"JGMO2YP65ZZXRmv'); - }); - - it('multiple params', async () => { - expect( - getFormBoundary('multipart/form-data; test=abc; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv') - ).to.equal('----WebKitFormBoundaryeJGMO2YP65ZZXRmv'); - }); - - it('multiple params (switch order)', async () => { - expect( - getFormBoundary('multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv; test=abc') - ).to.equal('----WebKitFormBoundaryeJGMO2YP65ZZXRmv'); - }); - - it('extra boundary inside quoted string', async () => { - expect( - getFormBoundary( - 'multipart/form-data; test="boundary=nope"; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv' - ) - ).to.equal('----WebKitFormBoundaryeJGMO2YP65ZZXRmv'); - }); - - it('extra boundary inside quoted string (switch order)', async () => { - expect( - getFormBoundary( - 'multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv; test="boundary=nope"' - ) - ).to.equal('----WebKitFormBoundaryeJGMO2YP65ZZXRmv'); - }); - - it('missing boundary', async () => { - expect(() => getFormBoundary('multipart/form-data; oops=----WebKitFormBoundaryeJGMO2YP65ZZXRmv')).to.throw( - /failed to find.*boundary/i - ); - }); - }); -}); - -describe('parseContentDisposition', () => { - // Largely uses the same logic as parseContentType, so only going to add a simple test - it('normal', async () => { - const [disposition, params] = parseContentDisposition('form-data; name=myfile; filename="test.txt"'); - expect(disposition).to.equal('form-data'); - expect(params.get('name')).to.equal('myfile'); - expect(params.get('filename')).to.equal('test.txt'); - }); -}); diff --git a/types/.npmignore b/types/.npmignore deleted file mode 100644 index 85a1c5cb..00000000 --- a/types/.npmignore +++ /dev/null @@ -1,7 +0,0 @@ -# **NOTE**: This file is only used if you pack locally. -# The official build/release pipeline has separate logic to control the package contents in "./azure-pipelines/build.yml". - -*.test.ts -tsconfig.json -dist -*.tgz \ No newline at end of file diff --git a/types/LICENSE b/types/LICENSE deleted file mode 100644 index a33fe1c5..00000000 --- a/types/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ - MIT License - - Copyright (c) .NET Foundation. All rights reserved. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE diff --git a/types/README.md b/types/README.md deleted file mode 100644 index bcc876f6..00000000 --- a/types/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Type definitions for Azure Functions -This package contains type definitions for using TypeScript with Azure Functions. Follow [this tutorial](https://docs.microsoft.com/azure/azure-functions/create-first-function-vs-code-typescript) to create your first TypeScript function. - -# Versioning -The version of the package matches the version of the [Node.js worker](https://github.com/Azure/azure-functions-nodejs-worker). It is recommended to install the latest version of the package matching the major version of your worker. - -|Worker Version|[Runtime Version](https://docs.microsoft.com/azure/azure-functions/functions-versions)|Support level|Node.js Versions| -|---|---|---|---| -|3|4|GA (Recommended)|16, 14| -|2|3|GA|14, 12, 10| -|1|2|GA (Maintenance mode)|10, 8| - -# Install -Because this package only contains type definitions, it should be saved under `devDependencies`. - -`npm install @azure/functions --save-dev` - -# Usage -```typescript -import { AzureFunction, Context, HttpRequest } from "@azure/functions"; - -const index: AzureFunction = async function (context: Context, req: HttpRequest) { - context.log('JavaScript HTTP trigger function processed a request.'); - if (req.query.name || (req.body && req.body.name)) { - context.res = { - status: "200", - body: "Hello " + (req.query.name || req.body.name) - }; - } else { - context.res = { - status: 400, - body: "Please pass a name on the query string or in the request body" - }; - } -} - -export { index }; -``` - -# Contributing - -See "Contributing" section on the Node.js worker repo [here](https://github.com/Azure/azure-functions-nodejs-worker#contributing). diff --git a/types/index.d.ts b/types/index.d.ts deleted file mode 100644 index 8db52bfc..00000000 --- a/types/index.d.ts +++ /dev/null @@ -1,532 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -declare module '@azure/functions' { - /** - * Interface for your Azure Function code. This function must be exported (via module.exports or exports) - * and will execute when triggered. It is recommended that you declare this function as async, which - * implicitly returns a Promise. - * @param context Context object passed to your function from the Azure Functions runtime. - * @param {any[]} args Optional array of input and trigger binding data. These binding data are passed to the - * function in the same order that they are defined in function.json. Valid input types are string, HttpRequest, - * and Buffer. - * @returns Output bindings (optional). If you are returning a result from a Promise (or an async function), this - * result will be passed to JSON.stringify unless it is a string, Buffer, ArrayBufferView, or number. - */ - export type AzureFunction = (context: Context, ...args: any[]) => Promise | void; - - /** - * Context bindings object. Provided to your function binding data, as defined in function.json. - */ - export interface ContextBindings { - [name: string]: any; - } - /** - * Context binding data. Provided to your function trigger metadata and function invocation data. - */ - export interface ContextBindingData { - /** - * A unique GUID per function invocation. - */ - invocationId: string; - - [name: string]: any; - } - /** - * The context object can be used for writing logs, reading data from bindings, setting outputs and using - * the context.done callback when your exported function is synchronous. A context object is passed - * to your function from the Azure Functions runtime on function invocation. - */ - export interface Context { - /** - * A unique GUID per function invocation. - */ - invocationId: string; - /** - * Function execution metadata. - */ - executionContext: ExecutionContext; - /** - * Input and trigger binding data, as defined in function.json. Properties on this object are dynamically - * generated and named based off of the "name" property in function.json. - */ - bindings: ContextBindings; - /** - * Trigger metadata and function invocation data. - */ - bindingData: ContextBindingData; - /** - * TraceContext information to enable distributed tracing scenarios. - */ - traceContext: TraceContext; - /** - * Bindings your function uses, as defined in function.json. - */ - bindingDefinitions: BindingDefinition[]; - /** - * Allows you to write streaming function logs. Calling directly allows you to write streaming function logs - * at the default trace level. - */ - log: Logger; - /** - * A callback function that signals to the runtime that your code has completed. If your function is synchronous, - * you must call context.done at the end of execution. If your function is asynchronous, you should not use this - * callback. - * - * @param err A user-defined error to pass back to the runtime. If present, your function execution will fail. - * @param result An object containing output binding data. `result` will be passed to JSON.stringify unless it is - * a string, Buffer, ArrayBufferView, or number. - * - * @deprecated Use of sync functions with `context.done()` is not recommended. Use async/await and pass the response as the return value instead. - * See the docs here for more information: https://aka.ms/functions-js-async-await - */ - done(err?: Error | string | null, result?: any): void; - /** - * HTTP request object. Provided to your function when using HTTP Bindings. - */ - req?: HttpRequest; - /** - * HTTP response object. Provided to your function when using HTTP Bindings. - */ - res?: { - [key: string]: any; - }; - } - /** - * HTTP request headers. - */ - export interface HttpRequestHeaders { - [name: string]: string; - } - /** - * HTTP response headers. - */ - export interface HttpResponseHeaders { - [name: string]: string; - } - /** - * Query string parameter keys and values from the URL. - */ - export interface HttpRequestQuery { - [name: string]: string; - } - /** - * Route parameter keys and values. - */ - export interface HttpRequestParams { - [name: string]: string; - } - /** - * Object representing logged-in user, either through - * AppService/Functions authentication, or SWA Authentication - */ - export interface HttpRequestUser { - /** - * Type of authentication, either AppService or StaticWebApps - */ - type: HttpRequestUserType; - /** - * unique user GUID - */ - id: string; - /** - * unique username - */ - username: string; - /** - * provider of authentication service - */ - identityProvider: string; - /** - * Extra authentication information, dependent on auth type - * and auth provider - */ - claimsPrincipalData: { - [key: string]: any; - }; - } - /** - * HTTP request object. Provided to your function when using HTTP Bindings. - */ - export interface HttpRequest { - /** - * HTTP request method used to invoke this function. - */ - method: HttpMethod | null; - /** - * Request URL. - */ - url: string; - /** - * HTTP request headers. - */ - headers: HttpRequestHeaders; - /** - * Query string parameter keys and values from the URL. - */ - query: HttpRequestQuery; - /** - * Route parameter keys and values. - */ - params: HttpRequestParams; - /** - * Object representing logged-in user, either through - * AppService/Functions authentication, or SWA Authentication - * null when no such user is logged in. - */ - user: HttpRequestUser | null; - /** - * The HTTP request body. - */ - body?: any; - /** - * The HTTP request body as a UTF-8 string. - */ - rawBody?: any; - - /** - * Parses the body and returns an object representing a form - * @throws if the content type is not "multipart/form-data" or "application/x-www-form-urlencoded" - */ - parseFormBody(): Form; - } - - export interface Form extends Iterable<[string, FormPart]> { - /** - * Returns the value of the first name-value pair whose name is `name`. If there are no such pairs, `null` is returned. - */ - get(name: string): FormPart | null; - - /** - * Returns the values of all name-value pairs whose name is `name`. If there are no such pairs, an empty array is returned. - */ - getAll(name: string): FormPart[]; - - /** - * Returns `true` if there is at least one name-value pair whose name is `name`. - */ - has(name: string): boolean; - - /** - * The number of parts in this form - */ - length: number; - } - - export interface FormPart { - /** - * The value for this part of the form - */ - value: Buffer; - - /** - * The file name for this part of the form, if specified - */ - fileName?: string; - - /** - * The content type for this part of the form, assumed to be "text/plain" if not specified - */ - contentType?: string; - } - - /** - * Possible values for an HTTP request method. - */ - export type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'HEAD' | 'PATCH' | 'PUT' | 'OPTIONS' | 'TRACE' | 'CONNECT'; - /** - * Possible values for an HTTP Request user type - */ - export type HttpRequestUserType = 'AppService' | 'StaticWebApps'; - /** - * Http response object and methods. - * This is the default of the res property in the Context object provided to your function when using HTTP triggers. - */ - export interface HttpResponseFull { - /** - * HTTP response headers. - */ - headers?: HttpResponseHeaders; - /** - * HTTP response cookies. - */ - cookies?: Cookie[]; - /** - * HTTP response body. - */ - body?: any; - /** - * HTTP response status code. - * @default 200 - */ - statusCode?: number | string; - /** - * Enable content negotiation of response body if true - * If false, treat response body as raw - * @default false - */ - enableContentNegotiation?: boolean; - /** - * Sets the HTTP response status code - * @returns the updated HttpResponseFull instance - */ - status: (statusCode: number | string) => HttpResponseFull; - /** - * Sets a particular header field to a value - * @returns the updated HttpResponseFull instance - */ - setHeader(field: string, val: any): HttpResponseFull; - /** - * Has the same functionality as setHeader. - * Sets a particular header field to a value - * @returns the updated HttpResponseFull instance - */ - header(field: string, val: any): HttpResponseFull; - /** - * Has the same functionality as setHeader. - * Sets a particular header field to a value - * @returns the updated HttpResponseFull instance - */ - set(field: string, val: any): HttpResponseFull; - /** - * Get the value of a particular header field - */ - getHeader(field: string): any; - /** - * Has the same functionality as getHeader - * Get the value of a particular header field - */ - get(field: string): any; - /** - * Removes a particular header field - * @returns the updated HttpResponseFull instance - */ - removeHeader(field: string): HttpResponseFull; - /** - * Set the 'Content-Type' header to a particular value - * @returns the updated HttpResponseFull instance - */ - type(type: string): HttpResponseFull; - /** - * Automatically sets the content-type then calls context.done() - * @returns updated HttpResponseFull instance - * @deprecated this method calls context.done() which is deprecated, use async/await and pass the response as the return value instead. - * See the docs here for more information: https://aka.ms/functions-js-async-await - */ - send(body?: any): HttpResponseFull; - /** - * Same as send() - * Automatically sets the content-type then calls context.done() - * @returns updated HttpResponseFull instance - * @deprecated this method calls context.done() which is deprecated, use async/await and pass the response as your function's return value instead. - * See the docs here for more information: https://aka.ms/functions-js-async-await - */ - end(body?: any): HttpResponseFull; - /** - * Sets the status code then calls send() - * @returns updated HttpResponseFull instance - * @deprecated this method calls context.done() which is deprecated, use async/await and pass the response as your function's return value instead. - * See the docs here for more information: https://aka.ms/functions-js-async-await - */ - sendStatus(statusCode: string | number): HttpResponseFull; - /** - * Sets the 'Content-Type' header to 'application/json' then calls send(body) - * @deprecated this method calls context.done() which is deprecated, use async/await and pass the response as your function's return value instead. - * See the docs here for more information: https://aka.ms/functions-js-async-await - */ - json(body?: any): void; - } - /** - * Http response object. - * This is not the default on the Context object, but you may replace context.res with an object of this type when using HTTP triggers. - */ - export interface HttpResponseSimple { - /** - * HTTP response headers. - */ - headers?: HttpResponseHeaders; - /** - * HTTP response cookies. - */ - cookies?: Cookie[]; - /** - * HTTP response body. - */ - body?: any; - /** - * HTTP response status code. - * This property takes precedence over the `status` property - * @default 200 - */ - statusCode?: number | string; - /** - * HTTP response status code - * The same as `statusCode`. This property is ignored if `statusCode` is set - * @default 200 - */ - status?: number | string; - /** - * Enable content negotiation of response body if true - * If false, treat response body as raw - * @default false - */ - enableContentNegotiation?: boolean; - } - /** - * Http response type. - */ - export type HttpResponse = HttpResponseSimple | HttpResponseFull; - - /** - * Http response cookie object to "Set-Cookie" - */ - export interface Cookie { - /** Cookie name */ - name: string; - /** Cookie value */ - value: string; - /** Specifies allowed hosts to receive the cookie */ - domain?: string; - /** Specifies URL path that must exist in the requested URL */ - path?: string; - /** - * NOTE: It is generally recommended that you use maxAge over expires. - * Sets the cookie to expire at a specific date instead of when the client closes. - * This can be a Javascript Date or Unix time in milliseconds. - */ - expires?: Date | number; - /** Sets the cookie to only be sent with an encrypted request */ - secure?: boolean; - /** Sets the cookie to be inaccessible to JavaScript's Document.cookie API */ - httpOnly?: boolean; - /** Can restrict the cookie to not be sent with cross-site requests */ - sameSite?: 'Strict' | 'Lax' | 'None' | undefined; - /** Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. */ - maxAge?: number; - } - export interface ExecutionContext { - /** - * A unique GUID per function invocation. - */ - invocationId: string; - /** - * The name of the function that is being invoked. The name of your function is always the same as the - * name of the corresponding function.json's parent directory. - */ - functionName: string; - /** - * The directory your function is in (this is the parent directory of this function's function.json). - */ - functionDirectory: string; - /** - * The retry context of the current function execution or null if the retry policy is not defined. - */ - retryContext: RetryContext | null; - } - export interface RetryContext { - /** - * Current retry count of the function executions. - */ - retryCount: number; - /** - * Max retry count is the maximum number of times an execution is retried before eventual failure. A value of -1 means to retry indefinitely. - */ - maxRetryCount: number; - /** - * Exception that caused the retry - */ - exception?: Exception; - } - export interface Exception { - /** Exception source */ - source?: string | null; - /** Exception stackTrace */ - stackTrace?: string | null; - /** Exception message */ - message?: string | null; - } - /** - * TraceContext information to enable distributed tracing scenarios. - */ - export interface TraceContext { - /** Describes the position of the incoming request in its trace graph in a portable, fixed-length format. */ - traceparent: string | null | undefined; - /** Extends traceparent with vendor-specific data. */ - tracestate: string | null | undefined; - /** Holds additional properties being sent as part of request telemetry. */ - attributes: - | { - [k: string]: string; - } - | null - | undefined; - } - export interface BindingDefinition { - /** - * The name of your binding, as defined in function.json. - */ - name: string; - /** - * The type of your binding, as defined in function.json. - */ - type: string; - /** - * The direction of your binding, as defined in function.json. - */ - direction: 'in' | 'out' | 'inout' | undefined; - } - /** - * Allows you to write streaming function logs. - */ - export interface Logger { - /** - * Writes streaming function logs at the default trace level. - */ - (...args: any[]): void; - /** - * Writes to error level logging or lower. - */ - error(...args: any[]): void; - /** - * Writes to warning level logging or lower. - */ - warn(...args: any[]): void; - /** - * Writes to info level logging or lower. - */ - info(...args: any[]): void; - /** - * Writes to verbose level logging. - */ - verbose(...args: any[]): void; - } - /** - * Timer schedule information. Provided to your function when using a timer binding. - */ - export interface Timer { - /** - * Whether this timer invocation is due to a missed schedule occurrence. - */ - isPastDue: boolean; - schedule: { - /** - * Whether intervals between invocations should account for DST. - */ - adjustForDST: boolean; - }; - scheduleStatus: { - /** - * The last recorded schedule occurrence. Date ISO string. - */ - last: string; - /** - * The expected next schedule occurrence. Date ISO string. - */ - next: string; - /** - * The last time this record was updated. This is used to re-calculate `next` with the current schedule after a host restart. Date ISO string. - */ - lastUpdated: string; - }; - } -} diff --git a/types/index.test.ts b/types/index.test.ts deleted file mode 100644 index 8d157632..00000000 --- a/types/index.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. - -// This file will be compiled by multiple versions of TypeScript as decribed in ./test/TypesTests.ts to verify there are no errors - -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable deprecation/deprecation */ -import { - AzureFunction, - Context, - Cookie, - HttpMethod, - HttpRequest, - HttpResponseFull, - HttpResponseSimple, - Timer, -} from '@azure/functions'; -const get: HttpMethod = 'GET'; - -const runHttp: AzureFunction = async function (context: Context, req: HttpRequest) { - if (req.method === get) { - context.log("This is a 'GET' method"); - } - - context.log('JavaScript HTTP trigger function processed a request.'); - if (req.query.name || (req.body && req.body.name)) { - context.res = { - status: '200', - body: 'Hello ' + (req.query.name || req.body.name), - }; - } else { - context.res = { - status: 400, - body: 'Please pass a name on the query string or in the request body', - }; - } -}; - -export const timerTrigger: AzureFunction = async function (context: Context, myTimer: Timer): Promise { - const timeStamp = new Date().toISOString(); - - if (myTimer.isPastDue) { - context.log('Timer function is running late!'); - } - context.log('Timer trigger function ran!', timeStamp); -}; - -const runServiceBus: AzureFunction = function (context: Context, myQueueItem: string) { - context.log('Node.js ServiceBus queue trigger function processed message', myQueueItem); - context.log.verbose('EnqueuedTimeUtc =', context.bindingData.enqueuedTimeUtc); - context.log.verbose('DeliveryCount =', context.bindingData.deliveryCount); - context.log.verbose('MessageId =', context.bindingData.messageId); - context.done(); -}; - -// Assumes output binding is named '$return' -const runHttpReturn: AzureFunction = async function (context: Context, req: HttpRequest) { - context.log('JavaScript HTTP trigger function processed a request.'); - if (req.query.name || (req.body && req.body.name)) { - return { - status: '200', - body: 'Hello ' + (req.query.name || req.body.name), - }; - } else { - return { - status: 400, - body: 'Please pass a name on the query string or in the request body', - }; - } -}; - -const runFunction: AzureFunction = async function (context: Context) { - context.log('Ran function'); - return 'Ran function'; -}; - -const cookieFunction: AzureFunction = async function (context: Context) { - const cookies: Cookie[] = [ - { - name: 'cookiename', - value: 'cookievalue', - expires: Date.now(), - }, - ]; - context.res = { - cookies, - body: 'just a normal body', - }; -}; - -const httpResponseSimpleFunction: AzureFunction = async function (context: Context) { - context.res = context.res as HttpResponseSimple; - context.res = { - body: { - hello: 'world', - }, - status: 200, - statusCode: 200, - headers: { - 'content-type': 'application/json', - }, - cookies: [ - { - name: 'cookiename', - value: 'cookievalue', - expires: Date.now(), - }, - ], - enableContentNegotiation: false, - }; -}; - -const statusStringFunction: AzureFunction = async function (context: Context) { - context.res = context.res as HttpResponseSimple; - context.res = { - status: '200', - statusCode: '200', - }; -}; - -const httpResponseFullFunction: AzureFunction = async function (context: Context) { - context.res = context.res as HttpResponseFull; - context.res.status(200); - context.res.setHeader('hello', 'world'); - context.res.set('hello', 'world'); - context.res.header('hello', 'world'); - const hello: string = context.res.get('hello'); - const hello2: string = context.res.getHeader('hello'); - context.res.removeHeader('hello'); - context.res.type('application/json'); - context.res.body = { - hello, - hello2, - }; - context.res.cookies = [ - { - name: 'cookiename', - value: 'cookievalue', - expires: Date.now(), - }, - ]; - context.res.enableContentNegotiation = false; -}; - -const runHttpWithQueue: AzureFunction = async function (context: Context, req: HttpRequest, queueItem: Buffer) { - context.log('Http-triggered function with ' + req.method + ' method.'); - context.log('Pulling in queue item ' + queueItem); - return; -}; - -const returnWithContextDone: AzureFunction = function (context: Context, _req: HttpRequest) { - context.log.info('Writing to queue'); - context.done(null, { myOutput: { text: 'hello there, world', noNumber: true } }); -}; - -const returnWithContextDoneMethods: AzureFunction = function (context: Context, _req: HttpRequest) { - context.res = context.res as HttpResponseFull; - context.res.send('hello world'); - context.res.end('hello world'); - context.res.sendStatus(200); -}; - -const returnWithJson: AzureFunction = function (context: Context, req: HttpRequest) { - if (context.res?.status instanceof Function) { - context.res.status(200).json({ - hello: 'world', - }); - } -}; - -export { - runHttp, - cookieFunction, - httpResponseFullFunction, - httpResponseSimpleFunction, - statusStringFunction, - runHttpReturn, - runServiceBus, - runFunction, - runHttpWithQueue, - returnWithContextDone, - returnWithContextDoneMethods, - returnWithJson, -}; - -// Function returns custom object -interface CustomOutput { - value: string; -} -export const runTypedReturn: AzureFunction = async (_context, _request: HttpRequest): Promise => { - // return { // ts(2322) error - // value1: "Test1" - // }; - return { - value: 'Test', - }; -}; - -export const runTypedReturn1: AzureFunction = async (_context, _request): Promise => { - // return { // ts(2322) error - // value1: "Test1" - // }; - return { - value: 'Test', - }; -}; diff --git a/types/package.json b/types/package.json deleted file mode 100644 index 1990cff5..00000000 --- a/types/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@azure/functions", - "version": "3.4.0", - "description": "Azure Functions types for Typescript", - "repository": { - "type": "git", - "url": "https://github.com/Azure/azure-functions-nodejs-worker/tree/v3.x/types" - }, - "keywords": [ - "azure-functions", - "typescript" - ], - "types": "./index.d.ts", - "author": "Microsoft", - "license": "MIT" -} diff --git a/types/tsconfig.json b/types/tsconfig.json deleted file mode 100644 index 34d4f722..00000000 --- a/types/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "es6", - "strict": true, - "outDir": "dist" - } -} \ No newline at end of file From 6e9d5d9ddf0653aa7e17569d717ed1e1afa72c1b Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Fri, 15 Jul 2022 13:23:48 -0700 Subject: [PATCH 2/8] Use newly separated programming model package --- azure-pipelines/build.yml | 26 -- package-lock.json | 53 +-- package.json | 11 +- scripts/updateVersion.ts | 31 +- src/FunctionLoader.ts | 55 ++- src/WorkerChannel.ts | 3 +- src/constants.ts | 12 - src/eventHandlers/InvocationHandler.ts | 276 ++++------- src/setupCoreModule.ts | 31 +- test/FunctionLoader.test.ts | 14 +- test/eventHandlers/InvocationHandler.test.ts | 137 +++--- test/eventHandlers/TestEventStream.ts | 3 +- test/eventHandlers/WorkerInitHandler.test.ts | 6 - test/eventHandlers/beforeEventHandlerSuite.ts | 25 +- tsconfig.json | 3 - types-core/index.d.ts | 428 +++++++++++++++++- 16 files changed, 684 insertions(+), 430 deletions(-) diff --git a/azure-pipelines/build.yml b/azure-pipelines/build.yml index 2a5fc1a0..b6df1845 100644 --- a/azure-pipelines/build.yml +++ b/azure-pipelines/build.yml @@ -68,38 +68,12 @@ jobs: publishVstsFeed: 'e6a70c92-4128-439f-8012-382fe78d6396/f37f760c-aebd-443e-9714-ce725cd427df' allowPackageConflicts: true displayName: 'Push NuGet package to the AzureFunctionsPreRelease feed' - # In order for the SBOM to be accurate, we want to explicitly specify the components folder, but the types package doesn't have any components - # We'll create an empty folder that _would_ store the components if it had any - - bash: | - mkdir types/node_modules - displayName: 'mkdir types/node_modules' - - task: CopyFiles@2 - displayName: 'Copy types files to staging' - inputs: - sourceFolder: '$(Build.SourcesDirectory)/types' - contents: | - index.d.ts - LICENSE - package.json - README.md - targetFolder: '$(Build.ArtifactStagingDirectory)/types' - cleanTargetFolder: true - - task: ManifestGeneratorTask@0 - displayName: 'Generate SBOM for types' - inputs: - BuildDropPath: '$(Build.ArtifactStagingDirectory)/types' - BuildComponentPath: '$(Build.SourcesDirectory)/types/node_modules' - PackageName: 'Azure Functions Type Definitions' - - script: npm pack - displayName: 'npm pack types' - workingDirectory: '$(Build.ArtifactStagingDirectory)/types' - task: CopyFiles@2 displayName: 'Copy packages to staging drop folder' inputs: sourceFolder: '$(Build.ArtifactStagingDirectory)' contents: | worker/*.nupkg - types/*.tgz targetFolder: '$(Build.ArtifactStagingDirectory)/drop' cleanTargetFolder: true - task: PublishPipelineArtifact@1 diff --git a/package-lock.json b/package-lock.json index bb832ef2..8e956ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,12 @@ "version": "3.4.0", "license": "(MIT OR Apache-2.0)", "dependencies": { + "@azure/functions": "file:../js-framework/azure-functions-3.4.0.tgz", "@grpc/grpc-js": "^1.2.7", "@grpc/proto-loader": "^0.6.4", "blocked-at": "^1.2.0", "fs-extra": "^10.0.1", - "long": "^4.0.0", - "minimist": "^1.2.5", - "uuid": "^8.3.0" + "minimist": "^1.2.5" }, "devDependencies": { "@types/blocked-at": "^1.0.1", @@ -53,12 +52,21 @@ "sinon": "^7.0.0", "ts-node": "^3.3.0", "typescript": "^4.5.5", - "typescript3": "npm:typescript@~3.7.0", - "typescript4": "npm:typescript@~4.0.0", "webpack": "^5.72.1", "webpack-cli": "^4.8.0" } }, + "node_modules/@azure/functions": { + "version": "3.4.0", + "resolved": "file:../js-framework/azure-functions-3.4.0.tgz", + "integrity": "sha512-cS/xoLy0mHyf/Dplf5ueC7KNK4BWutpPZXnMjJbUa36VlAmFESlKpNn6UtFsHAP5oxAuYHKhGhUXWqWKEnqHew==", + "license": "MIT", + "dependencies": { + "fs-extra": "^10.0.1", + "long": "^4.0.0", + "uuid": "^8.3.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.12.11", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", @@ -4327,20 +4335,6 @@ "node": ">=4.2.0" } }, - "node_modules/typescript3": { - "name": "typescript", - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.7.tgz", - "integrity": "sha512-MmQdgo/XenfZPvVLtKZOq9jQQvzaUAUpcKW8Z43x9B2fOm4S5g//tPtMweZUIP+SoBqrVPEIm+dJeQ9dfO0QdA==", - "dev": true - }, - "node_modules/typescript4": { - "name": "typescript", - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.8.tgz", - "integrity": "sha512-oz1765PN+imfz1MlZzSZPtC/tqcwsCyIYA8L47EkRnRW97ztRk83SzMiWLrnChC0vqoYxSU1fcFUDA5gV/ZiPg==", - "dev": true - }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -4658,6 +4652,15 @@ } }, "dependencies": { + "@azure/functions": { + "version": "file:../js-framework/azure-functions-3.4.0.tgz", + "integrity": "sha512-cS/xoLy0mHyf/Dplf5ueC7KNK4BWutpPZXnMjJbUa36VlAmFESlKpNn6UtFsHAP5oxAuYHKhGhUXWqWKEnqHew==", + "requires": { + "fs-extra": "^10.0.1", + "long": "^4.0.0", + "uuid": "^8.3.0" + } + }, "@babel/code-frame": { "version": "7.12.11", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", @@ -7878,18 +7881,6 @@ "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "dev": true }, - "typescript3": { - "version": "npm:typescript@3.7.7", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.7.tgz", - "integrity": "sha512-MmQdgo/XenfZPvVLtKZOq9jQQvzaUAUpcKW8Z43x9B2fOm4S5g//tPtMweZUIP+SoBqrVPEIm+dJeQ9dfO0QdA==", - "dev": true - }, - "typescript4": { - "version": "npm:typescript@4.0.8", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.8.tgz", - "integrity": "sha512-oz1765PN+imfz1MlZzSZPtC/tqcwsCyIYA8L47EkRnRW97ztRk83SzMiWLrnChC0vqoYxSU1fcFUDA5gV/ZiPg==", - "dev": true - }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/package.json b/package.json index 4d910c93..cee75926 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,12 @@ "description": "Microsoft Azure Functions NodeJS Worker", "license": "(MIT OR Apache-2.0)", "dependencies": { + "@azure/functions": "file:../js-framework/azure-functions-3.4.0.tgz", "@grpc/grpc-js": "^1.2.7", "@grpc/proto-loader": "^0.6.4", "blocked-at": "^1.2.0", "fs-extra": "^10.0.1", - "long": "^4.0.0", - "minimist": "^1.2.5", - "uuid": "^8.3.0" + "minimist": "^1.2.5" }, "devDependencies": { "@types/blocked-at": "^1.0.1", @@ -49,8 +48,6 @@ "sinon": "^7.0.0", "ts-node": "^3.3.0", "typescript": "^4.5.5", - "typescript3": "npm:typescript@~3.7.0", - "typescript4": "npm:typescript@~4.0.0", "webpack": "^5.72.1", "webpack-cli": "^4.8.0" }, @@ -64,13 +61,13 @@ }, "scripts": { "clean": "rimraf dist && rimraf azure-functions-language-worker-protobuf/src/rpc*", - "build": "rimraf dist && npm run gen && shx mkdir -p dist/azure-functions-language-worker-protobuf/src && shx cp azure-functions-language-worker-protobuf/src/rpc.* dist/azure-functions-language-worker-protobuf/src/. && node ./node_modules/typescript/bin/tsc", + "build": "rimraf dist && npm run gen && shx mkdir -p dist/azure-functions-language-worker-protobuf/src && shx cp azure-functions-language-worker-protobuf/src/rpc.* dist/azure-functions-language-worker-protobuf/src/. && tsc", "gen": "node scripts/generateProtos.js", "test": "mocha -r ts-node/register \"./test/**/*.ts\" --reporter mocha-multi-reporters --reporter-options configFile=test/mochaReporterOptions.json", "lint": "eslint .", "lint-fix": "eslint . --fix", "updateVersion": "ts-node ./scripts/updateVersion.ts", - "watch": "node ./node_modules/typescript/bin/tsc --watch", + "watch": "tsc --watch", "webpack": "webpack --mode production" }, "files": [ diff --git a/scripts/updateVersion.ts b/scripts/updateVersion.ts index cf2ee494..0641b1f3 100644 --- a/scripts/updateVersion.ts +++ b/scripts/updateVersion.ts @@ -9,8 +9,6 @@ import * as semver from 'semver'; const repoRoot = path.join(__dirname, '..'); const packageJsonPath = path.join(repoRoot, 'package.json'); -const typesRoot = path.join(repoRoot, 'types'); -const typesPackageJsonPath = path.join(typesRoot, 'package.json'); const nuspecPath = path.join(repoRoot, 'Worker.nuspec'); const nuspecVersionRegex = /(.*)\$prereleaseSuffix\$<\/version>/i; const constantsPath = path.join(repoRoot, 'src', 'constants.ts'); @@ -24,9 +22,6 @@ if (args.validate) { } else { console.log(`This script can be used to either update the version of the worker or validate that the repo is in a valid state with regards to versioning. -NOTE: For the types package, only the major & minor version need to match the worker. We follow the same pattern as DefinitelyTyped as described here: -https://github.com/DefinitelyTyped/DefinitelyTyped#how-do-definitely-typed-package-versions-relate-to-versions-of-the-corresponding-library - Example usage: npm run updateVersion -- --version 3.3.0 @@ -39,35 +34,21 @@ function validateVersion() { const packageJson = readJSONSync(packageJsonPath); const packageJsonVersion = packageJson.version; - const typesPackageJson = readJSONSync(typesPackageJsonPath); - const typesPackageJsonVersion = typesPackageJson.version; - const nuspecVersion = getVersion(nuspecPath, nuspecVersionRegex); const constantsVersion = getVersion(constantsPath, constantsVersionRegex); console.log('Found the following versions:'); console.log(`- package.json: ${packageJsonVersion}`); - console.log(`- types/package.json: ${typesPackageJsonVersion}`); console.log(`- Worker.nuspec: ${nuspecVersion}`); console.log(`- src/constants.ts: ${constantsVersion}`); const parsedVersion = semver.parse(packageJsonVersion); - const parsedTypesVersion = semver.parse(typesPackageJsonVersion); - - if ( - !packageJsonVersion || - !nuspecVersion || - !constantsVersion || - !typesPackageJsonVersion || - !parsedVersion || - !parsedTypesVersion - ) { + + if (!packageJsonVersion || !nuspecVersion || !constantsVersion || !parsedVersion) { throw new Error('Failed to detect valid versions in all expected files'); } else if (nuspecVersion !== packageJsonVersion || constantsVersion !== packageJsonVersion) { throw new Error(`Worker versions do not match.`); - } else if (parsedVersion.major !== parsedTypesVersion.major || parsedVersion.minor !== parsedTypesVersion.minor) { - throw new Error(`Types package does not match the major/minor version of the worker.`); } else { console.log('Versions match! 🎉'); } @@ -84,15 +65,7 @@ function getVersion(filePath: string, regex: RegExp): string { function updateVersion(newVersion: string) { updatePackageJsonVersion(repoRoot, newVersion); - - if (newVersion.endsWith('.0')) { - updatePackageJsonVersion(typesRoot, newVersion); - } else { - console.log(`Skipping types/package.json because this is a patch version.`); - } - updateVersionByRegex(nuspecPath, nuspecVersionRegex, newVersion); - updateVersionByRegex(constantsPath, constantsVersionRegex, newVersion); } diff --git a/src/FunctionLoader.ts b/src/FunctionLoader.ts index 2edb5165..b3868d1e 100644 --- a/src/FunctionLoader.ts +++ b/src/FunctionLoader.ts @@ -1,8 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. +import { FunctionCallback } from '@azure/functions-core'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { FunctionInfo } from './FunctionInfo'; import { loadScriptFile } from './loadScriptFile'; import { PackageJson } from './parsers/parsePackageJson'; import { InternalException } from './utils/InternalException'; @@ -10,18 +10,18 @@ import { nonNullProp } from './utils/nonNull'; export interface IFunctionLoader { load(functionId: string, metadata: rpc.IRpcFunctionMetadata, packageJson: PackageJson): Promise; - getInfo(functionId: string): FunctionInfo; - getFunc(functionId: string): Function; + getRpcMetadata(functionId: string): rpc.IRpcFunctionMetadata; + getCallback(functionId: string): FunctionCallback; } -export class FunctionLoader implements IFunctionLoader { - #loadedFunctions: { - [k: string]: { - info: FunctionInfo; - func: Function; - thisArg: unknown; - }; - } = {}; +interface LoadedFunction { + metadata: rpc.IRpcFunctionMetadata; + callback: FunctionCallback; + thisArg: unknown; +} + +export class FunctionLoader { + #loadedFunctions: { [k: string]: LoadedFunction | undefined } = {}; async load(functionId: string, metadata: rpc.IRpcFunctionMetadata, packageJson: PackageJson): Promise { if (metadata.isProxy === true) { @@ -29,35 +29,32 @@ export class FunctionLoader implements IFunctionLoader { } const script: any = await loadScriptFile(nonNullProp(metadata, 'scriptFile'), packageJson); const entryPoint = (metadata && metadata.entryPoint); - const [userFunction, thisArg] = getEntryPoint(script, entryPoint); - this.#loadedFunctions[functionId] = { - info: new FunctionInfo(metadata), - func: userFunction, - thisArg, - }; + const [callback, thisArg] = getEntryPoint(script, entryPoint); + this.#loadedFunctions[functionId] = { metadata, callback, thisArg }; } - getInfo(functionId: string): FunctionInfo { - const loadedFunction = this.#loadedFunctions[functionId]; - if (loadedFunction && loadedFunction.info) { - return loadedFunction.info; - } else { - throw new InternalException(`Function info for '${functionId}' is not loaded and cannot be invoked.`); - } + getRpcMetadata(functionId: string): rpc.IRpcFunctionMetadata { + const loadedFunction = this.#getLoadedFunction(functionId); + return loadedFunction.metadata; + } + + getCallback(functionId: string): FunctionCallback { + const loadedFunction = this.#getLoadedFunction(functionId); + // `bind` is necessary to set the `this` arg, but it's also nice because it makes a clone of the function, preventing this invocation from affecting future invocations + return loadedFunction.callback.bind(loadedFunction.thisArg); } - getFunc(functionId: string): Function { + #getLoadedFunction(functionId: string): LoadedFunction { const loadedFunction = this.#loadedFunctions[functionId]; - if (loadedFunction && loadedFunction.func) { - // `bind` is necessary to set the `this` arg, but it's also nice because it makes a clone of the function, preventing this invocation from affecting future invocations - return loadedFunction.func.bind(loadedFunction.thisArg); + if (loadedFunction) { + return loadedFunction; } else { throw new InternalException(`Function code for '${functionId}' is not loaded and cannot be invoked.`); } } } -function getEntryPoint(f: any, entryPoint?: string): [Function, unknown] { +function getEntryPoint(f: any, entryPoint?: string): [FunctionCallback, unknown] { let thisArg: unknown; if (f !== null && typeof f === 'object') { thisArg = f; diff --git a/src/WorkerChannel.ts b/src/WorkerChannel.ts index 80cee7d2..19b84e9f 100644 --- a/src/WorkerChannel.ts +++ b/src/WorkerChannel.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { HookCallback, HookContext, HookData } from '@azure/functions-core'; +import { HookCallback, HookContext, HookData, ProgrammingModel } from '@azure/functions-core'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import { Disposable } from './Disposable'; import { IFunctionLoader } from './FunctionLoader'; @@ -27,6 +27,7 @@ export class WorkerChannel { * this hook data is limited to the app-level scope and persisted only for app-level hooks */ appLevelOnlyHookData: HookData = {}; + programmingModel?: ProgrammingModel; #preInvocationHooks: HookCallback[] = []; #postInvocationHooks: HookCallback[] = []; #appStartHooks: HookCallback[] = []; diff --git a/src/constants.ts b/src/constants.ts index 7bb79566..5ec56a16 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,15 +2,3 @@ // Licensed under the MIT License. export const version = '3.4.0'; - -export enum HeaderName { - contentType = 'content-type', - contentDisposition = 'content-disposition', -} - -export enum MediaType { - multipartForm = 'multipart/form-data', - urlEncodedForm = 'application/x-www-form-urlencoded', - octetStream = 'application/octet-stream', - json = 'application/json', -} diff --git a/src/eventHandlers/InvocationHandler.ts b/src/eventHandlers/InvocationHandler.ts index 9a55dd97..8179841f 100644 --- a/src/eventHandlers/InvocationHandler.ts +++ b/src/eventHandlers/InvocationHandler.ts @@ -1,18 +1,23 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { AzureFunction } from '@azure/functions'; -import { HookData, PostInvocationContext, PreInvocationContext } from '@azure/functions-core'; -import { format } from 'util'; +import * as coreTypes from '@azure/functions-core'; +import { + HookData, + InvocationState, + PostInvocationContext, + PreInvocationContext, + ProgrammingModel, + RpcFunctionMetadata, + RpcInvocationRequest, +} from '@azure/functions-core'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { CreateContextAndInputs } from '../Context'; -import { toTypedData } from '../converters/RpcConverters'; import { isError } from '../utils/ensureErrorType'; import { nonNullProp } from '../utils/nonNull'; import { WorkerChannel } from '../WorkerChannel'; import { EventHandler } from './EventHandler'; -import LogCategory = rpc.RpcLog.RpcLogCategory; -import LogLevel = rpc.RpcLog.Level; +import RpcLogCategory = rpc.RpcLog.RpcLogCategory; +import RpcLogLevel = rpc.RpcLog.Level; /** * Host requests worker to invoke a Function @@ -25,187 +30,100 @@ export class InvocationHandler extends EventHandler<'invocationRequest', 'invoca } async handleEvent(channel: WorkerChannel, msg: rpc.IInvocationRequest): Promise { - const response = this.getDefaultResponse(msg); - - const invocationId = nonNullProp(msg, 'invocationId'); const functionId = nonNullProp(msg, 'functionId'); + const metadata = channel.functionLoader.getRpcMetadata(functionId); + const msgCategory = `${nonNullProp(metadata, 'name')}.Invocation`; + const coreCtx = new CoreInvocationContext(channel, msg, metadata, msgCategory); - // explicitly set outputData to empty array to concat later - response.outputData = []; - - let isDone = false; - let isExecutingPostInvocationHooks = false; - let resultIsPromise = false; - - const info = channel.functionLoader.getInfo(functionId); - const asyncDoneLearnMoreLink = 'https://go.microsoft.com/fwlink/?linkid=2097909'; - - const msgCategory = `${info.name}.Invocation`; - function log(level: LogLevel, logCategory: LogCategory, ...args: any[]) { - channel.log({ - invocationId: invocationId, - category: msgCategory, - message: format.apply(null, <[any, any[]]>args), - level: level, - logCategory, - }); - } - function systemLog(level: LogLevel, ...args: any[]) { - log(level, LogCategory.System, ...args); - } - function userLog(level: LogLevel, ...args: any[]) { - if (isDone && !isExecutingPostInvocationHooks) { - 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 or calls to 'done' made before function execution completes. "; - badAsyncMsg += `Function name: ${info.name}. Invocation Id: ${invocationId}. `; - badAsyncMsg += `Learn more: ${asyncDoneLearnMoreLink}`; - systemLog(LogLevel.Warning, badAsyncMsg); - } - log(level, LogCategory.User, ...args); + // Log invocation details to ensure the invocation received by node worker + coreCtx.log(RpcLogLevel.Debug, RpcLogCategory.System, 'Received FunctionInvocationRequest'); + + const programmingModel: ProgrammingModel = nonNullProp(channel, 'programmingModel'); + const invocModel = programmingModel.getInvocationModel(coreCtx); + + const hookData: HookData = {}; + let { context, inputs } = await invocModel.getArguments(); + let callback = channel.functionLoader.getCallback(functionId); + + const preInvocContext: PreInvocationContext = { + hookData, + appHookData: channel.appHookData, + invocationContext: context, + functionCallback: callback, + inputs, + }; + + coreCtx.state = 'preInvocationHooks'; + try { + await channel.executeHooks('preInvocation', preInvocContext, msg.invocationId, msgCategory); + } finally { + coreCtx.state = undefined; } - // Log invocation details to ensure the invocation received by node worker - systemLog(LogLevel.Debug, 'Received FunctionInvocationRequest'); - - function onDone(): void { - if (isDone) { - const message = resultIsPromise - ? `Error: Choose either to return a promise or call 'done'. Do not use both in your script. Learn more: ${asyncDoneLearnMoreLink}` - : "Error: 'done' has already been called. Please check your script for extraneous calls to 'done'."; - systemLog(LogLevel.Error, message); - } - isDone = true; + inputs = preInvocContext.inputs; + callback = preInvocContext.functionCallback; + + const postInvocContext: PostInvocationContext = { + hookData, + appHookData: channel.appHookData, + invocationContext: context, + inputs, + result: null, + error: null, + }; + + coreCtx.state = 'invocation'; + try { + postInvocContext.result = await invocModel.invokeFunction(context, inputs, callback); + } catch (err) { + postInvocContext.error = err; + } finally { + coreCtx.state = undefined; } - let { context, inputs, doneEmitter } = CreateContextAndInputs(info, msg, userLog); + coreCtx.state = 'postInvocationHooks'; try { - const legacyDoneTask = new Promise((resolve, reject) => { - doneEmitter.on('done', (err?: unknown, result?: any) => { - onDone(); - if (isError(err)) { - reject(err); - } else { - resolve(result); - } - }); - }); - - let userFunction = channel.functionLoader.getFunc(functionId); - - const invocationHookData: HookData = {}; - - const preInvocContext: PreInvocationContext = { - hookData: invocationHookData, - appHookData: channel.appHookData, - invocationContext: context, - functionCallback: userFunction, - inputs, - }; - await channel.executeHooks('preInvocation', preInvocContext, invocationId, msgCategory); - inputs = preInvocContext.inputs; - userFunction = preInvocContext.functionCallback; - - let rawResult = userFunction(context, ...inputs); - resultIsPromise = rawResult && typeof rawResult.then === 'function'; - let resultTask: Promise; - if (resultIsPromise) { - rawResult = Promise.resolve(rawResult).then((r) => { - onDone(); - return r; - }); - resultTask = Promise.race([rawResult, legacyDoneTask]); - } else { - resultTask = legacyDoneTask; - } - - const postInvocContext: PostInvocationContext = { - hookData: invocationHookData, - appHookData: channel.appHookData, - invocationContext: context, - inputs, - result: null, - error: null, - }; - try { - postInvocContext.result = await resultTask; - } catch (err) { - postInvocContext.error = err; - } - - try { - isExecutingPostInvocationHooks = true; - await channel.executeHooks('postInvocation', postInvocContext, msg.invocationId, msgCategory); - } finally { - isExecutingPostInvocationHooks = false; - } - - if (isError(postInvocContext.error)) { - throw postInvocContext.error; - } - const result = postInvocContext.result; - - // 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; - } - - // 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'; - - const returnBinding = info.getReturnBinding(); - // Set results from return / context.done - 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 { - response.outputData = Object.keys(info.outputBindings) - .filter((key) => result[key] !== undefined) - .map( - (key) => - { - 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 && result[key] !== undefined; - return definedInBindings && !definedInReturn; - }) - .map( - (key) => - { - name: key, - data: info.outputBindings[key].converter(context.bindings[key]), - } - ) - ); - } + await channel.executeHooks('postInvocation', postInvocContext, msg.invocationId, msgCategory); } finally { - isDone = true; + coreCtx.state = undefined; + } + + if (isError(postInvocContext.error)) { + throw postInvocContext.error; } - return response; + return await invocModel.getResponse(context, postInvocContext.result); + } +} + +class CoreInvocationContext implements coreTypes.CoreInvocationContext { + invocationId: string; + request: RpcInvocationRequest; + metadata: RpcFunctionMetadata; + state?: InvocationState; + #channel: WorkerChannel; + #msgCategory: string; + + constructor( + channel: WorkerChannel, + request: RpcInvocationRequest, + metadata: RpcFunctionMetadata, + msgCategory: string + ) { + this.invocationId = nonNullProp(request, 'invocationId'); + this.#channel = channel; + this.request = request; + this.metadata = metadata; + this.#msgCategory = msgCategory; + } + + log(level: RpcLogLevel, logCategory: RpcLogCategory, message: string): void { + this.#channel.log({ + invocationId: this.request.invocationId, + category: this.#msgCategory, + message, + level: level, + logCategory: logCategory, + }); } } diff --git a/src/setupCoreModule.ts b/src/setupCoreModule.ts index 3b16c3a4..74f50ff5 100644 --- a/src/setupCoreModule.ts +++ b/src/setupCoreModule.ts @@ -1,11 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { HookCallback } from '@azure/functions-core'; +import { HookCallback, ProgrammingModel } from '@azure/functions-core'; +import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import { version } from './constants'; import { Disposable } from './Disposable'; import { WorkerChannel } from './WorkerChannel'; import Module = require('module'); +import LogCategory = rpc.RpcLog.RpcLogCategory; +import LogLevel = rpc.RpcLog.Level; /** * Intercepts the default "require" method so that we can provide our own "built-in" module @@ -14,9 +17,27 @@ import Module = require('module'); */ export function setupCoreModule(channel: WorkerChannel): void { const coreApi = { + version: version, registerHook: (hookName: string, callback: HookCallback) => channel.registerHook(hookName, callback), + setProgrammingModel: (programmingModel: ProgrammingModel) => { + // Log when setting the programming model, except for the initial default one (partially because the grpc channels aren't fully setup at that time) + if (channel.programmingModel) { + channel.log({ + message: `Setting Node.js programming model to "${programmingModel.name}" version "${programmingModel.version}"`, + level: LogLevel.Information, + logCategory: LogCategory.System, + }); + } + channel.programmingModel = programmingModel; + }, + getProgrammingModel: () => { + return channel.programmingModel; + }, Disposable, - version: version, + // NOTE: We have to pass along any and all enums used in the RPC api to the core api + RpcLog: rpc.RpcLog, + RpcBindingInfo: rpc.BindingInfo, + RpcHttpCookie: rpc.RpcHttpCookie, }; Module.prototype.require = new Proxy(Module.prototype.require, { @@ -28,4 +49,10 @@ export function setupCoreModule(channel: WorkerChannel): void { } }, }); + + // Set default programming model shipped with the worker + // This has to be imported dynamically _after_ we setup the core module since it will almost certainly reference the core module + // eslint-disable-next-line @typescript-eslint/no-var-requires + const func: typeof import('@azure/functions') = require('@azure/functions'); + func.setup(); } diff --git a/test/FunctionLoader.test.ts b/test/FunctionLoader.test.ts index cfebdfd9..f0556f86 100644 --- a/test/FunctionLoader.test.ts +++ b/test/FunctionLoader.test.ts @@ -53,7 +53,7 @@ describe('FunctionLoader', () => { ); expect(() => { - loader.getFunc('functionId'); + loader.getCallback('functionId'); }).to.throw("Function code for 'functionId' is not loaded and cannot be invoked."); }); @@ -118,7 +118,7 @@ describe('FunctionLoader', () => { {} ); - const userFunction = loader.getFunc('functionId'); + const userFunction = loader.getCallback('functionId'); userFunction(context, (results) => { expect(results).to.eql({ prop: true }); @@ -137,11 +137,11 @@ describe('FunctionLoader', () => { {} ); - const userFunction = loader.getFunc('functionId'); - const result = userFunction(); + const userFunction = loader.getCallback('functionId'); + const result = userFunction({}); expect(result).to.be.not.an('undefined'); - expect(result.then).to.be.a('function'); + expect((result).then).to.be.a('function'); }); it("function returned is a clone so that it can't affect other executions", async () => { @@ -156,10 +156,10 @@ describe('FunctionLoader', () => { {} ); - const userFunction = loader.getFunc('functionId'); + const userFunction = loader.getCallback('functionId'); Object.assign(userFunction, { hello: 'world' }); - const userFunction2 = loader.getFunc('functionId'); + const userFunction2 = loader.getCallback('functionId'); expect(userFunction).to.not.equal(userFunction2); expect(userFunction['hello']).to.equal('world'); diff --git a/test/eventHandlers/InvocationHandler.test.ts b/test/eventHandlers/InvocationHandler.test.ts index 70970025..cd852f0d 100644 --- a/test/eventHandlers/InvocationHandler.test.ts +++ b/test/eventHandlers/InvocationHandler.test.ts @@ -9,7 +9,6 @@ import { expect } from 'chai'; import 'mocha'; import * as sinon from 'sinon'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; -import { FunctionInfo } from '../../src/FunctionInfo'; import { FunctionLoader } from '../../src/FunctionLoader'; import { WorkerChannel } from '../../src/WorkerChannel'; import { Msg as AppStartMsg } from '../startApp.test'; @@ -324,7 +323,7 @@ namespace InputData { describe('InvocationHandler', () => { let stream: TestEventStream; - let loader: sinon.SinonStubbedInstance; + let loader: sinon.SinonStubbedInstance>; let channel: WorkerChannel; let coreApi: typeof coreTypes; let testDisposables: coreTypes.Disposable[] = []; @@ -382,8 +381,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.basic) { it('invokes function' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); await stream.assertCalledWith( Msg.receivedInvocLog(), @@ -395,8 +394,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.returnHttp) { it('returns correct data with $return binding' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.httpReturn)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.httpReturn); sendInvokeMessage([InputData.http]); const expectedOutput = getHttpResponse(undefined, '$return'); const expectedReturnValue = { @@ -416,8 +415,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.returnArray) { it('returns returned output if not http' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); sendInvokeMessage([]); const expectedReturnValue = { json: '["hello, seattle!","hello, tokyo!"]', @@ -428,8 +427,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.returnArray) { it('returned output is ignored if http' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([]); await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResponse([], undefined)); }); @@ -437,8 +436,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.resHttp) { it('serializes output binding data through context.done' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); const expectedOutput = [getHttpResponse({ hello: 'world' })]; await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResponse(expectedOutput)); @@ -447,18 +446,16 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.multipleBindings) { it('serializes multiple output bindings through context.done and context.bindings' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns( - new FunctionInfo({ - bindings: { - req: Binding.httpInput, - res: Binding.httpOutput, - queueOutput: Binding.queueOutput, - overriddenQueueOutput: Binding.queueOutput, - }, - name: 'testFuncName', - }) - ); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns({ + bindings: { + req: Binding.httpInput, + res: Binding.httpOutput, + queueOutput: Binding.queueOutput, + overriddenQueueOutput: Binding.queueOutput, + }, + name: 'testFuncName', + }); sendInvokeMessage([InputData.http]); const expectedOutput = [ getHttpResponse({ hello: 'world' }), @@ -481,8 +478,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.error) { it('returns failed status for user error' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); sendInvokeMessage([InputData.http]); await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResFailed); }); @@ -497,17 +494,17 @@ describe('InvocationHandler', () => { }); it('empty function does not return invocation response', async () => { - loader.getFunc.returns(() => {}); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getCallback.returns(() => {}); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); await stream.assertCalledWith(Msg.receivedInvocLog()); }); it('logs error on calling context.done in async function', async () => { - loader.getFunc.returns(async (context: Context) => { + loader.getCallback.returns(async (context: Context) => { context.done(); }); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); await stream.assertCalledWith( Msg.receivedInvocLog(), @@ -517,11 +514,11 @@ describe('InvocationHandler', () => { }); it('logs error on calling context.done more than once', async () => { - loader.getFunc.returns((context: Context) => { + loader.getCallback.returns((context: Context) => { context.done(); context.done(); }); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); await stream.assertCalledWith( Msg.receivedInvocLog(), @@ -531,11 +528,11 @@ describe('InvocationHandler', () => { }); it('logs error on calling context.log after context.done', async () => { - loader.getFunc.returns((context: Context) => { + loader.getCallback.returns((context: Context) => { context.done(); context.log('testUserLog'); }); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); await stream.assertCalledWith( Msg.receivedInvocLog(), @@ -547,11 +544,11 @@ describe('InvocationHandler', () => { it('logs error on calling context.log after async function', async () => { let _context: Context; - loader.getFunc.returns(async (context: Context) => { + loader.getCallback.returns(async (context: Context) => { _context = context; return 'hello'; }); - loader.getInfo.returns(new FunctionInfo(Binding.httpRes)); + loader.getRpcMetadata.returns(Binding.httpRes); sendInvokeMessage([InputData.http]); // wait for first two messages to ensure invocation happens await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResponse([getHttpResponse()])); @@ -562,8 +559,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.logHookData) { it('preInvocationHook' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('preInvocation', () => { @@ -585,8 +582,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.logInput) { it('preInvocationHook respects change to inputs' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { @@ -608,13 +605,13 @@ describe('InvocationHandler', () => { } it('preInvocationHook respects change to functionCallback', async () => { - loader.getFunc.returns(async (invocContext: Context) => { + loader.getCallback.returns(async (invocContext: Context) => { invocContext.log('old function'); }); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( - coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { expect(context.functionCallback).to.be.a('function'); context.functionCallback = async (invocContext: Context) => { invocContext.log('new function'); @@ -634,15 +631,15 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.logHookData) { it('postInvocationHook' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { hookData += 'post'; expect(context.result).to.equal('hello'); expect(context.error).to.be.null; - context.invocationContext.log('hello from post'); + (context.invocationContext).log('hello from post'); }) ); @@ -661,8 +658,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.logHookData) { it('postInvocationHook respects change to context.result' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { @@ -687,8 +684,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.error) { it('postInvocationHook executes if function throws error' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { @@ -711,8 +708,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.error) { it('postInvocationHook respects change to context.error' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('postInvocation', (context: coreTypes.PostInvocationContext) => { @@ -736,8 +733,8 @@ describe('InvocationHandler', () => { } it('pre and post invocation hooks share data', async () => { - loader.getFunc.returns(async () => {}); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(async () => {}); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { @@ -790,8 +787,8 @@ describe('InvocationHandler', () => { ); expect(startFunc.callCount).to.be.equal(1); - loader.getFunc.returns(async () => {}); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(async () => {}); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { @@ -843,8 +840,8 @@ describe('InvocationHandler', () => { ); expect(startFunc.callCount).to.be.equal(1); - loader.getFunc.returns(async () => {}); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(async () => {}); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { @@ -883,8 +880,8 @@ describe('InvocationHandler', () => { }, }; - loader.getFunc.returns(async () => {}); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(async () => {}); + loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { @@ -927,8 +924,8 @@ describe('InvocationHandler', () => { }, }; - loader.getFunc.returns(async () => {}); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(async () => {}); + loader.getRpcMetadata.returns(Binding.queue); const pre1 = coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { Object.assign(context.appHookData, expectedAppHookData); @@ -986,8 +983,8 @@ describe('InvocationHandler', () => { }); it('dispose hooks', async () => { - loader.getFunc.returns(async () => {}); - loader.getInfo.returns(new FunctionInfo(Binding.queue)); + loader.getCallback.returns(async () => {}); + loader.getRpcMetadata.returns(Binding.queue); const disposableA: coreTypes.Disposable = coreApi.registerHook('preInvocation', () => { hookData += 'a'; @@ -1025,8 +1022,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.returnEmptyString) { it('returns and serializes falsy value in Durable: ""' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.activity)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.activity); sendInvokeMessage([]); const expectedReturnValue = { string: '' }; await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResponse([], expectedReturnValue)); @@ -1035,8 +1032,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.returnZero) { it('returns and serializes falsy value in Durable: 0' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.activity)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.activity); sendInvokeMessage([]); const expectedReturnValue = { int: 0 }; await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResponse([], expectedReturnValue)); @@ -1045,8 +1042,8 @@ describe('InvocationHandler', () => { for (const [func, suffix] of TestFunc.returnFalse) { it('returns and serializes falsy value in Durable: false' + suffix, async () => { - loader.getFunc.returns(func); - loader.getInfo.returns(new FunctionInfo(Binding.activity)); + loader.getCallback.returns(func); + loader.getRpcMetadata.returns(Binding.activity); sendInvokeMessage([]); const expectedReturnValue = { json: 'false' }; await stream.assertCalledWith(Msg.receivedInvocLog(), Msg.invocResponse([], expectedReturnValue)); diff --git a/test/eventHandlers/TestEventStream.ts b/test/eventHandlers/TestEventStream.ts index 97b9c09d..b454c472 100644 --- a/test/eventHandlers/TestEventStream.ts +++ b/test/eventHandlers/TestEventStream.ts @@ -6,7 +6,6 @@ import { EventEmitter } from 'events'; import * as sinon from 'sinon'; import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; import { IEventStream } from '../../src/GrpcClient'; -import { Response } from '../../src/http/Response'; export class TestEventStream extends EventEmitter implements IEventStream { written: sinon.SinonSpy; @@ -118,7 +117,7 @@ function getShortenedMsg(msg: rpc.IStreamingMessage | RegExpStreamingMessage): s function convertHttpResponse(msg: rpc.IStreamingMessage): rpc.IStreamingMessage { if (msg.invocationResponse?.outputData) { for (const entry of msg.invocationResponse.outputData) { - if (entry.data?.http instanceof Response) { + if (entry.data?.http) { const res = entry.data.http; entry.data.http = { body: res.body, diff --git a/test/eventHandlers/WorkerInitHandler.test.ts b/test/eventHandlers/WorkerInitHandler.test.ts index 01edf667..b0716341 100644 --- a/test/eventHandlers/WorkerInitHandler.test.ts +++ b/test/eventHandlers/WorkerInitHandler.test.ts @@ -255,9 +255,6 @@ describe('WorkerInitHandler', () => { } it('Fails for missing entry point', async function (this: ITestCallbackContext) { - // Should be re-enabled after https://github.com/Azure/azure-functions-nodejs-worker/pull/577 - this.skip(); - const fileName = 'entryPointFiles/missing.js'; const expectedPackageJson = { main: fileName, @@ -279,9 +276,6 @@ describe('WorkerInitHandler', () => { }); it('Fails for invalid entry point', async function (this: ITestCallbackContext) { - // Should be re-enabled after https://github.com/Azure/azure-functions-nodejs-worker/pull/577 - this.skip(); - const fileName = 'entryPointFiles/throwError.js'; const expectedPackageJson = { main: fileName, diff --git a/test/eventHandlers/beforeEventHandlerSuite.ts b/test/eventHandlers/beforeEventHandlerSuite.ts index c543c4ec..8460dadf 100644 --- a/test/eventHandlers/beforeEventHandlerSuite.ts +++ b/test/eventHandlers/beforeEventHandlerSuite.ts @@ -8,11 +8,24 @@ import { setupEventStream } from '../../src/setupEventStream'; import { WorkerChannel } from '../../src/WorkerChannel'; import { TestEventStream } from './TestEventStream'; +let testWorkerData: + | { + stream: TestEventStream; + loader: sinon.SinonStubbedInstance; + channel: WorkerChannel; + } + | undefined = undefined; + export function beforeEventHandlerSuite() { - const stream = new TestEventStream(); - const loader = sinon.createStubInstance(FunctionLoader); - const channel = new WorkerChannel(stream, loader); - setupEventStream('workerId', channel); - setupCoreModule(channel); - return { stream, loader, channel }; + if (!testWorkerData) { + const stream = new TestEventStream(); + const loader = sinon.createStubInstance(FunctionLoader); + const channel = new WorkerChannel(stream, loader); + setupEventStream('workerId', channel); + setupCoreModule(channel); + testWorkerData = { stream, loader, channel }; + // Clear out logs that happened during setup, so that they don't affect whichever test runs first + stream.written.resetHistory(); + } + return testWorkerData; } diff --git a/tsconfig.json b/tsconfig.json index 44222174..131a6c70 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,9 +9,6 @@ "sourceMap": true, "baseUrl": "./", "paths": { - "@azure/functions": [ - "types" - ], "@azure/functions-core": [ "types-core" ] diff --git a/types-core/index.d.ts b/types-core/index.d.ts index 70750d61..0287fdd8 100644 --- a/types-core/index.d.ts +++ b/types-core/index.d.ts @@ -1,8 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { AzureFunction, Context } from '@azure/functions'; - /** * This module is shipped as a built-in part of the Azure Functions Node.js worker and is available at runtime */ @@ -10,28 +8,36 @@ declare module '@azure/functions-core' { /** * The version of the Node.js worker */ - export const version: string; + const version: string; /** * Register a hook to interact with the lifecycle of Azure Functions. * Hooks are executed in the order they were registered and will block execution if they throw an error */ - export function registerHook(hookName: 'preInvocation', callback: PreInvocationCallback): Disposable; - export function registerHook(hookName: 'postInvocation', callback: PostInvocationCallback): Disposable; - export function registerHook(hookName: 'appStart', callback: AppStartCallback): Disposable; - export function registerHook(hookName: string, callback: HookCallback): Disposable; + function registerHook( + hookName: 'preInvocation', + callback: PreInvocationCallback + ): Disposable; + function registerHook( + hookName: 'postInvocation', + callback: PostInvocationCallback + ): Disposable; + function registerHook(hookName: 'appStart', callback: AppStartCallback): Disposable; + function registerHook(hookName: string, callback: HookCallback): Disposable; - export type HookCallback = (context: HookContext) => void | Promise; - export type PreInvocationCallback = (context: PreInvocationContext) => void | Promise; - export type PostInvocationCallback = (context: PostInvocationContext) => void | Promise; - export type AppStartCallback = (context: AppStartContext) => void | Promise; + type HookCallback = (context: HookContext) => void | Promise; + type PreInvocationCallback = (context: PreInvocationContext) => void | Promise; + type PostInvocationCallback = ( + context: PostInvocationContext + ) => void | Promise; + type AppStartCallback = (context: AppStartContext) => void | Promise; - export type HookData = { [key: string]: any }; + type HookData = { [key: string]: any }; /** * Base interface for all hook context objects */ - export interface HookContext { + interface HookContext { /** * The recommended place to share data between hooks in the same scope (app-level vs invocation-level) */ @@ -46,11 +52,11 @@ declare module '@azure/functions-core' { * Context on a function that is about to be executed * This object will be passed to all pre invocation hooks */ - export interface PreInvocationContext extends HookContext { + interface PreInvocationContext extends HookContext { /** * The context object passed to the function */ - invocationContext: Context; + invocationContext: TContext; /** * The input values for this specific invocation. Changes to this array _will_ affect the inputs passed to your function @@ -60,18 +66,18 @@ declare module '@azure/functions-core' { /** * The function callback for this specific invocation. Changes to this value _will_ affect the function itself */ - functionCallback: AzureFunction; + functionCallback: FunctionCallback; } /** * Context on a function that has just executed * This object will be passed to all post invocation hooks */ - export interface PostInvocationContext extends HookContext { + interface PostInvocationContext extends HookContext { /** * The context object passed to the function */ - invocationContext: Context; + invocationContext: TContext; /** * The input values for this specific invocation @@ -93,7 +99,7 @@ declare module '@azure/functions-core' { * Context on a function app that is about to be started * This object will be passed to all app start hooks */ - export interface AppStartContext extends HookContext { + interface AppStartContext extends HookContext { /** * Absolute directory of the function app */ @@ -107,7 +113,7 @@ declare module '@azure/functions-core' { /** * Represents a type which can release resources, such as event listening or a timer. */ - export class Disposable { + class Disposable { /** * Combine many disposable-likes into one. You can use this method when having objects with a dispose function which aren't instances of `Disposable`. * @@ -129,4 +135,386 @@ declare module '@azure/functions-core' { */ dispose(): any; } + + /** + * Registers the main programming model to be used for a Node.js function app + * Only one programming model can be set. The last programming model registered will be used + * If not explicitly set, a default programming model included with the worker will be used + */ + function setProgrammingModel(programmingModel: ProgrammingModel): void; + + /** + * Returns the currently registered programming model + * If not explicitly set, a default programming model included with the worker will be used + */ + function getProgrammingModel(): ProgrammingModel; + + /** + * A set of information and methods that describe the model for handling a Node.js function app + * Currently, this is mainly focused on invocation + */ + interface ProgrammingModel { + /** + * A name for this programming model, generally only used for tracking purposes + */ + name: string; + + /** + * A version for this programming model, generally only used for tracking purposes + */ + version: string; + + /** + * Returns a new instance of the invocation model for each invocation + */ + getInvocationModel(coreContext: CoreInvocationContext): InvocationModel; + } + + /** + * Basic information and helper methods about an invocation provided from the core worker to the programming model + */ + interface CoreInvocationContext { + /** + * A guid unique to this invocation + */ + invocationId: string; + + /** + * The invocation request received by the worker from the host + */ + request: RpcInvocationRequest; + + /** + * Metadata about the function + */ + metadata: RpcFunctionMetadata; + + /** + * Describes the current state of invocation, or undefined if between states + */ + state?: InvocationState; + + /** + * The recommended way to log information + */ + log(level: RpcLog.Level, category: RpcLog.RpcLogCategory, message: string): void; + } + + type InvocationState = 'preInvocationHooks' | 'postInvocationHooks' | 'invocation'; + + /** + * A set of methods that describe the model for invoking a function + */ + interface InvocationModel { + /** + * Returns the context object and inputs to be passed to all following invocation methods + * This is run before preInvocation hooks + */ + getArguments(): Promise>; + + /** + * The main method that executes the user's function callback + * This is run between preInvocation and postInvocation hooks + * @param context The context object returned in `getArguments`, potentially modified by preInvocation hooks + * @param inputs The input array returned in `getArguments`, potentially modified by preInvocation hooks + * @param callback The function callback to be executed + */ + invokeFunction(context: TContext, inputs: unknown[], callback: FunctionCallback): Promise; + + /** + * Returns the invocation response to send back to the host + * This is run after postInvocation hooks + * @param context The context object created in `getArguments` + * @param result The result of the function callback, potentially modified by postInvocation hooks + */ + getResponse(context: TContext, result: unknown): Promise; + } + + interface InvocationArguments { + /** + * This is always the first argument passed to a function callback + */ + context: TContext; + + /** + * The remaining arguments passed to a function callback, generally describing the trigger/input bindings + */ + inputs: unknown[]; + } + + type FunctionCallback = (context: TContext, ...inputs: unknown[]) => unknown; + + // #region rpc types + interface RpcFunctionMetadata { + name?: string | null; + + directory?: string | null; + + scriptFile?: string | null; + + entryPoint?: string | null; + + bindings?: { [k: string]: RpcBindingInfo } | null; + + isProxy?: boolean | null; + + status?: RpcStatusResult | null; + + language?: string | null; + + rawBindings?: string[] | null; + + functionId?: string | null; + + managedDependencyEnabled?: boolean | null; + } + + interface RpcStatusResult { + status?: RpcStatusResult.Status | null; + + result?: string | null; + + exception?: RpcException | null; + + logs?: RpcLog[] | null; + } + + namespace RpcStatusResult { + enum Status { + Failure = 0, + Success = 1, + Cancelled = 2, + } + } + + interface RpcLog { + invocationId?: string | null; + + category?: string | null; + + level?: RpcLog.Level | null; + + message?: string | null; + + eventId?: string | null; + + exception?: RpcException | null; + + logCategory?: RpcLog.RpcLogCategory | null; + } + + namespace RpcLog { + enum Level { + Trace = 0, + Debug = 1, + Information = 2, + Warning = 3, + Error = 4, + Critical = 5, + None = 6, + } + + enum RpcLogCategory { + User = 0, + System = 1, + CustomMetric = 2, + } + } + + interface RpcException { + source?: string | null; + + stackTrace?: string | null; + + message?: string | null; + } + + interface RpcBindingInfo { + type?: string | null; + + direction?: RpcBindingInfo.Direction | null; + + dataType?: RpcBindingInfo.DataType | null; + } + + namespace RpcBindingInfo { + enum Direction { + in = 0, + out = 1, + inout = 2, + } + + enum DataType { + undefined = 0, + string = 1, + binary = 2, + stream = 3, + } + } + + interface RpcTypedData { + string?: string | null; + + json?: string | null; + + bytes?: Uint8Array | null; + + stream?: Uint8Array | null; + + http?: RpcHttpData | null; + + int?: number | Long | null; + + double?: number | null; + + collectionBytes?: RpcCollectionBytes | null; + + collectionString?: RpcCollectionString | null; + + collectionDouble?: RpcCollectionDouble | null; + + collectionSint64?: RpcCollectionSInt64 | null; + } + + interface RpcCollectionSInt64 { + sint64?: (number | Long)[] | null; + } + + interface RpcCollectionString { + string?: string[] | null; + } + + interface RpcCollectionBytes { + bytes?: Uint8Array[] | null; + } + + interface RpcCollectionDouble { + double?: number[] | null; + } + + interface RpcInvocationRequest { + invocationId?: string | null; + + functionId?: string | null; + + inputData?: RpcParameterBinding[] | null; + + triggerMetadata?: { [k: string]: RpcTypedData } | null; + + traceContext?: RpcTraceContext | null; + + retryContext?: RpcRetryContext | null; + } + + interface RpcTraceContext { + traceParent?: string | null; + + traceState?: string | null; + + attributes?: { [k: string]: string } | null; + } + + interface RpcRetryContext { + retryCount?: number | null; + + maxRetryCount?: number | null; + + exception?: RpcException | null; + } + + interface RpcInvocationResponse { + invocationId?: string | null; + + outputData?: RpcParameterBinding[] | null; + + returnValue?: RpcTypedData | null; + + result?: RpcStatusResult | null; + } + + interface RpcParameterBinding { + name?: string | null; + + data?: RpcTypedData | null; + } + + interface RpcHttpData { + method?: string | null; + + url?: string | null; + + headers?: { [k: string]: string } | null; + + body?: RpcTypedData | null; + + params?: { [k: string]: string } | null; + + statusCode?: string | null; + + query?: { [k: string]: string } | null; + + enableContentNegotiation?: boolean | null; + + rawBody?: RpcTypedData | null; + + cookies?: RpcHttpCookie[] | null; + + nullableHeaders?: { [k: string]: RpcNullableString } | null; + + nullableParams?: { [k: string]: RpcNullableString } | null; + + nullableQuery?: { [k: string]: RpcNullableString } | null; + } + + interface RpcHttpCookie { + name?: string | null; + + value?: string | null; + + domain?: RpcNullableString | null; + + path?: RpcNullableString | null; + + expires?: RpcNullableTimestamp | null; + + secure?: RpcNullableBool | null; + + httpOnly?: RpcNullableBool | null; + + sameSite?: RpcHttpCookie.SameSite | null; + + maxAge?: RpcNullableDouble | null; + } + + interface RpcNullableString { + value?: string | null; + } + + interface RpcNullableDouble { + value?: number | null; + } + + interface RpcNullableBool { + value?: boolean | null; + } + + interface RpcNullableTimestamp { + value?: RpcTimestamp | null; + } + + interface RpcTimestamp { + seconds?: number | Long | null; + + nanos?: number | null; + } + + namespace RpcHttpCookie { + enum SameSite { + None = 0, + Lax = 1, + Strict = 2, + ExplicitNone = 3, + } + } + // #endregion rpc types } From 8918f8379ab81eb7c516a5732067cf65ccbbf70b Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Tue, 9 Aug 2022 15:13:58 -0700 Subject: [PATCH 3/8] update comment --- types-core/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types-core/index.d.ts b/types-core/index.d.ts index 0287fdd8..4f6bf833 100644 --- a/types-core/index.d.ts +++ b/types-core/index.d.ts @@ -232,7 +232,7 @@ declare module '@azure/functions-core' { interface InvocationArguments { /** - * This is always the first argument passed to a function callback + * This is usually the first argument passed to a function callback */ context: TContext; From 48f2566022c9ee301d9095fb23c0c4873f1fc83b Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Tue, 9 Aug 2022 15:35:43 -0700 Subject: [PATCH 4/8] Get rid of parameterized TContext --- src/FunctionLoader.ts | 4 +- src/WorkerChannel.ts | 2 +- src/eventHandlers/InvocationHandler.ts | 2 +- src/setupCoreModule.ts | 2 +- test/eventHandlers/InvocationHandler.test.ts | 17 +++++-- types-core/index.d.ts | 48 ++++++++------------ 6 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/FunctionLoader.ts b/src/FunctionLoader.ts index b3868d1e..5001de5b 100644 --- a/src/FunctionLoader.ts +++ b/src/FunctionLoader.ts @@ -20,7 +20,7 @@ interface LoadedFunction { thisArg: unknown; } -export class FunctionLoader { +export class FunctionLoader { #loadedFunctions: { [k: string]: LoadedFunction | undefined } = {}; async load(functionId: string, metadata: rpc.IRpcFunctionMetadata, packageJson: PackageJson): Promise { @@ -38,7 +38,7 @@ export class FunctionLoader { return loadedFunction.metadata; } - getCallback(functionId: string): FunctionCallback { + getCallback(functionId: string): FunctionCallback { const loadedFunction = this.#getLoadedFunction(functionId); // `bind` is necessary to set the `this` arg, but it's also nice because it makes a clone of the function, preventing this invocation from affecting future invocations return loadedFunction.callback.bind(loadedFunction.thisArg); diff --git a/src/WorkerChannel.ts b/src/WorkerChannel.ts index 19b84e9f..336bf8cb 100644 --- a/src/WorkerChannel.ts +++ b/src/WorkerChannel.ts @@ -27,7 +27,7 @@ export class WorkerChannel { * this hook data is limited to the app-level scope and persisted only for app-level hooks */ appLevelOnlyHookData: HookData = {}; - programmingModel?: ProgrammingModel; + programmingModel?: ProgrammingModel; #preInvocationHooks: HookCallback[] = []; #postInvocationHooks: HookCallback[] = []; #appStartHooks: HookCallback[] = []; diff --git a/src/eventHandlers/InvocationHandler.ts b/src/eventHandlers/InvocationHandler.ts index 8179841f..f9b22e3e 100644 --- a/src/eventHandlers/InvocationHandler.ts +++ b/src/eventHandlers/InvocationHandler.ts @@ -38,7 +38,7 @@ export class InvocationHandler extends EventHandler<'invocationRequest', 'invoca // Log invocation details to ensure the invocation received by node worker coreCtx.log(RpcLogLevel.Debug, RpcLogCategory.System, 'Received FunctionInvocationRequest'); - const programmingModel: ProgrammingModel = nonNullProp(channel, 'programmingModel'); + const programmingModel: ProgrammingModel = nonNullProp(channel, 'programmingModel'); const invocModel = programmingModel.getInvocationModel(coreCtx); const hookData: HookData = {}; diff --git a/src/setupCoreModule.ts b/src/setupCoreModule.ts index 74f50ff5..b90750b8 100644 --- a/src/setupCoreModule.ts +++ b/src/setupCoreModule.ts @@ -19,7 +19,7 @@ export function setupCoreModule(channel: WorkerChannel): void { const coreApi = { version: version, registerHook: (hookName: string, callback: HookCallback) => channel.registerHook(hookName, callback), - setProgrammingModel: (programmingModel: ProgrammingModel) => { + setProgrammingModel: (programmingModel: ProgrammingModel) => { // Log when setting the programming model, except for the initial default one (partially because the grpc channels aren't fully setup at that time) if (channel.programmingModel) { channel.log({ diff --git a/test/eventHandlers/InvocationHandler.test.ts b/test/eventHandlers/InvocationHandler.test.ts index cd852f0d..f5addbc4 100644 --- a/test/eventHandlers/InvocationHandler.test.ts +++ b/test/eventHandlers/InvocationHandler.test.ts @@ -321,15 +321,22 @@ namespace InputData { }; } +type TestFunctionLoader = sinon.SinonStubbedInstance< + FunctionLoader & { getCallback(functionId: string): AzureFunction } +>; + describe('InvocationHandler', () => { let stream: TestEventStream; - let loader: sinon.SinonStubbedInstance>; + let loader: TestFunctionLoader; let channel: WorkerChannel; let coreApi: typeof coreTypes; let testDisposables: coreTypes.Disposable[] = []; before(async () => { - ({ stream, loader, channel } = beforeEventHandlerSuite()); + const result = beforeEventHandlerSuite(); + stream = result.stream; + loader = result.loader; + channel = result.channel; coreApi = await import('@azure/functions-core'); }); @@ -611,11 +618,11 @@ describe('InvocationHandler', () => { loader.getRpcMetadata.returns(Binding.queue); testDisposables.push( - coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { + coreApi.registerHook('preInvocation', (context: coreTypes.PreInvocationContext) => { expect(context.functionCallback).to.be.a('function'); - context.functionCallback = async (invocContext: Context) => { + context.functionCallback = (async (invocContext: Context) => { invocContext.log('new function'); - }; + }); }) ); diff --git a/types-core/index.d.ts b/types-core/index.d.ts index 4f6bf833..30713ed7 100644 --- a/types-core/index.d.ts +++ b/types-core/index.d.ts @@ -14,22 +14,14 @@ declare module '@azure/functions-core' { * Register a hook to interact with the lifecycle of Azure Functions. * Hooks are executed in the order they were registered and will block execution if they throw an error */ - function registerHook( - hookName: 'preInvocation', - callback: PreInvocationCallback - ): Disposable; - function registerHook( - hookName: 'postInvocation', - callback: PostInvocationCallback - ): Disposable; + function registerHook(hookName: 'preInvocation', callback: PreInvocationCallback): Disposable; + function registerHook(hookName: 'postInvocation', callback: PostInvocationCallback): Disposable; function registerHook(hookName: 'appStart', callback: AppStartCallback): Disposable; function registerHook(hookName: string, callback: HookCallback): Disposable; type HookCallback = (context: HookContext) => void | Promise; - type PreInvocationCallback = (context: PreInvocationContext) => void | Promise; - type PostInvocationCallback = ( - context: PostInvocationContext - ) => void | Promise; + type PreInvocationCallback = (context: PreInvocationContext) => void | Promise; + type PostInvocationCallback = (context: PostInvocationContext) => void | Promise; type AppStartCallback = (context: AppStartContext) => void | Promise; type HookData = { [key: string]: any }; @@ -52,11 +44,11 @@ declare module '@azure/functions-core' { * Context on a function that is about to be executed * This object will be passed to all pre invocation hooks */ - interface PreInvocationContext extends HookContext { + interface PreInvocationContext extends HookContext { /** * The context object passed to the function */ - invocationContext: TContext; + invocationContext: unknown; /** * The input values for this specific invocation. Changes to this array _will_ affect the inputs passed to your function @@ -66,18 +58,18 @@ declare module '@azure/functions-core' { /** * The function callback for this specific invocation. Changes to this value _will_ affect the function itself */ - functionCallback: FunctionCallback; + functionCallback: FunctionCallback; } /** * Context on a function that has just executed * This object will be passed to all post invocation hooks */ - interface PostInvocationContext extends HookContext { + interface PostInvocationContext extends HookContext { /** * The context object passed to the function */ - invocationContext: TContext; + invocationContext: unknown; /** * The input values for this specific invocation @@ -141,19 +133,19 @@ declare module '@azure/functions-core' { * Only one programming model can be set. The last programming model registered will be used * If not explicitly set, a default programming model included with the worker will be used */ - function setProgrammingModel(programmingModel: ProgrammingModel): void; + function setProgrammingModel(programmingModel: ProgrammingModel): void; /** * Returns the currently registered programming model * If not explicitly set, a default programming model included with the worker will be used */ - function getProgrammingModel(): ProgrammingModel; + function getProgrammingModel(): ProgrammingModel; /** * A set of information and methods that describe the model for handling a Node.js function app * Currently, this is mainly focused on invocation */ - interface ProgrammingModel { + interface ProgrammingModel { /** * A name for this programming model, generally only used for tracking purposes */ @@ -167,7 +159,7 @@ declare module '@azure/functions-core' { /** * Returns a new instance of the invocation model for each invocation */ - getInvocationModel(coreContext: CoreInvocationContext): InvocationModel; + getInvocationModel(coreContext: CoreInvocationContext): InvocationModel; } /** @@ -205,12 +197,12 @@ declare module '@azure/functions-core' { /** * A set of methods that describe the model for invoking a function */ - interface InvocationModel { + interface InvocationModel { /** * Returns the context object and inputs to be passed to all following invocation methods * This is run before preInvocation hooks */ - getArguments(): Promise>; + getArguments(): Promise; /** * The main method that executes the user's function callback @@ -219,7 +211,7 @@ declare module '@azure/functions-core' { * @param inputs The input array returned in `getArguments`, potentially modified by preInvocation hooks * @param callback The function callback to be executed */ - invokeFunction(context: TContext, inputs: unknown[], callback: FunctionCallback): Promise; + invokeFunction(context: unknown, inputs: unknown[], callback: FunctionCallback): Promise; /** * Returns the invocation response to send back to the host @@ -227,14 +219,14 @@ declare module '@azure/functions-core' { * @param context The context object created in `getArguments` * @param result The result of the function callback, potentially modified by postInvocation hooks */ - getResponse(context: TContext, result: unknown): Promise; + getResponse(context: unknown, result: unknown): Promise; } - interface InvocationArguments { + interface InvocationArguments { /** * This is usually the first argument passed to a function callback */ - context: TContext; + context: unknown; /** * The remaining arguments passed to a function callback, generally describing the trigger/input bindings @@ -242,7 +234,7 @@ declare module '@azure/functions-core' { inputs: unknown[]; } - type FunctionCallback = (context: TContext, ...inputs: unknown[]) => unknown; + type FunctionCallback = (context: unknown, ...inputs: unknown[]) => unknown; // #region rpc types interface RpcFunctionMetadata { From 085218d778e709a00c82eb454f5c807f2f1df78c Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Sat, 13 Aug 2022 10:49:35 -0700 Subject: [PATCH 5/8] implements interface --- src/FunctionLoader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FunctionLoader.ts b/src/FunctionLoader.ts index 5001de5b..28844cf5 100644 --- a/src/FunctionLoader.ts +++ b/src/FunctionLoader.ts @@ -20,7 +20,7 @@ interface LoadedFunction { thisArg: unknown; } -export class FunctionLoader { +export class FunctionLoader implements IFunctionLoader { #loadedFunctions: { [k: string]: LoadedFunction | undefined } = {}; async load(functionId: string, metadata: rpc.IRpcFunctionMetadata, packageJson: PackageJson): Promise { From 84dea686987c1b95cf60c8b3128bec092dc7a0c2 Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Thu, 18 Aug 2022 10:25:37 -0700 Subject: [PATCH 6/8] bump package --- package-lock.json | 26 +++++++++++++------------- package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e956ded..e402c19f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.4.0", "license": "(MIT OR Apache-2.0)", "dependencies": { - "@azure/functions": "file:../js-framework/azure-functions-3.4.0.tgz", + "@azure/functions": "^3.5.0-alpha.1", "@grpc/grpc-js": "^1.2.7", "@grpc/proto-loader": "^0.6.4", "blocked-at": "^1.2.0", @@ -57,10 +57,9 @@ } }, "node_modules/@azure/functions": { - "version": "3.4.0", - "resolved": "file:../js-framework/azure-functions-3.4.0.tgz", - "integrity": "sha512-cS/xoLy0mHyf/Dplf5ueC7KNK4BWutpPZXnMjJbUa36VlAmFESlKpNn6UtFsHAP5oxAuYHKhGhUXWqWKEnqHew==", - "license": "MIT", + "version": "3.5.0-alpha.1", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-3.5.0-alpha.1.tgz", + "integrity": "sha512-ubNgzisNtqUsOZ2ZcjLKoAG5bt0colb8KMzROYx8llXJnX4gOBqjkLDIPb4ZRF2lmht6+o9VMRHIw0YFk6sVrA==", "dependencies": { "fs-extra": "^10.0.1", "long": "^4.0.0", @@ -4353,9 +4352,9 @@ } }, "node_modules/uuid": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", - "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "bin": { "uuid": "dist/bin/uuid" } @@ -4653,8 +4652,9 @@ }, "dependencies": { "@azure/functions": { - "version": "file:../js-framework/azure-functions-3.4.0.tgz", - "integrity": "sha512-cS/xoLy0mHyf/Dplf5ueC7KNK4BWutpPZXnMjJbUa36VlAmFESlKpNn6UtFsHAP5oxAuYHKhGhUXWqWKEnqHew==", + "version": "3.5.0-alpha.1", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-3.5.0-alpha.1.tgz", + "integrity": "sha512-ubNgzisNtqUsOZ2ZcjLKoAG5bt0colb8KMzROYx8llXJnX4gOBqjkLDIPb4ZRF2lmht6+o9VMRHIw0YFk6sVrA==", "requires": { "fs-extra": "^10.0.1", "long": "^4.0.0", @@ -7896,9 +7896,9 @@ } }, "uuid": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", - "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==" + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/package.json b/package.json index cee75926..2dcd290c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Microsoft Azure Functions NodeJS Worker", "license": "(MIT OR Apache-2.0)", "dependencies": { - "@azure/functions": "file:../js-framework/azure-functions-3.4.0.tgz", + "@azure/functions": "^3.5.0-alpha.1", "@grpc/grpc-js": "^1.2.7", "@grpc/proto-loader": "^0.6.4", "blocked-at": "^1.2.0", From ec576259a28b794a6cfd875790aded890da72d8a Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Thu, 18 Aug 2022 10:47:14 -0700 Subject: [PATCH 7/8] fix webpack --- webpack.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 4ca90b1a..5fe5b99b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,7 +10,9 @@ module.exports = { node: { __dirname: false }, - externals: [], + externals: { + '@azure/functions-core': 'commonjs2 @azure/functions-core' + }, module: { parser: { javascript: { From 7b16140d163b1dd37336404799fbfb1a55e9aa0a Mon Sep 17 00:00:00 2001 From: Eric Jizba Date: Thu, 18 Aug 2022 11:48:37 -0700 Subject: [PATCH 8/8] fix unit tests once and for all --- src/eventHandlers/WorkerInitHandler.ts | 9 +++------ .../FunctionEnvironmentReloadHandler.test.ts | 10 ---------- test/eventHandlers/TestEventStream.ts | 13 +++++++++++++ test/startApp.test.ts | 11 +---------- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/eventHandlers/WorkerInitHandler.ts b/src/eventHandlers/WorkerInitHandler.ts index 00bd9eb2..4f8e5fac 100644 --- a/src/eventHandlers/WorkerInitHandler.ts +++ b/src/eventHandlers/WorkerInitHandler.ts @@ -63,17 +63,14 @@ export class WorkerInitHandler extends EventHandler<'workerInitRequest', 'worker export function logColdStartWarning(channel: WorkerChannel, delayInMs?: number): void { // On reading a js file with function code('require') NodeJs tries to find 'package.json' all the way up to the file system root. // In Azure files it causes a delay during cold start as connection to Azure Files is an expensive operation. - if ( - process.env.WEBSITE_CONTENTAZUREFILECONNECTIONSTRING && - process.env.WEBSITE_CONTENTSHARE && - process.env.AzureWebJobsScriptRoot - ) { + const scriptRoot = process.env.AzureWebJobsScriptRoot; + if (process.env.WEBSITE_CONTENTAZUREFILECONNECTIONSTRING && process.env.WEBSITE_CONTENTSHARE && scriptRoot) { // Add delay to avoid affecting coldstart if (!delayInMs) { delayInMs = 5000; } setTimeout(() => { - access(path.join(process.env.AzureWebJobsScriptRoot!, 'package.json'), constants.F_OK, (err) => { + access(path.join(scriptRoot, 'package.json'), constants.F_OK, (err) => { if (isError(err)) { channel.log({ message: diff --git a/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts b/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts index 71fac252..b4d4308e 100644 --- a/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts +++ b/test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts @@ -70,23 +70,13 @@ describe('FunctionEnvironmentReloadHandler', () => { let stream: TestEventStream; let channel: WorkerChannel; - // Reset `process.env` and process.cwd() after this test suite so it doesn't affect other tests - let originalEnv: NodeJS.ProcessEnv; - let originalCwd: string; before(() => { - originalEnv = { ...process.env }; - originalCwd = process.cwd(); ({ stream, channel } = beforeEventHandlerSuite()); channel.hostVersion = '2.7.0'; }); - after(() => { - Object.assign(process.env, originalEnv); - }); - afterEach(async () => { mock.restore(); - process.chdir(originalCwd); await stream.afterEachEventHandlerTest(); }); diff --git a/test/eventHandlers/TestEventStream.ts b/test/eventHandlers/TestEventStream.ts index b454c472..e1e1d84e 100644 --- a/test/eventHandlers/TestEventStream.ts +++ b/test/eventHandlers/TestEventStream.ts @@ -8,10 +8,14 @@ import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language import { IEventStream } from '../../src/GrpcClient'; export class TestEventStream extends EventEmitter implements IEventStream { + originalEnv: NodeJS.ProcessEnv; + originalCwd: string; written: sinon.SinonSpy; constructor() { super(); this.written = sinon.spy(); + this.originalEnv = { ...process.env }; + this.originalCwd = process.cwd(); } write(message: rpc.IStreamingMessage) { this.written(message); @@ -69,6 +73,15 @@ export class TestEventStream extends EventEmitter implements IEventStream { * Verifies the test didn't send any extraneous messages */ async afterEachEventHandlerTest(): Promise { + // Reset `process.env` and process.cwd() after each test so it doesn't affect other tests + process.chdir(this.originalCwd); + for (const key of Object.keys(process.env)) { + if (!(key in this.originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, this.originalEnv); + // minor delay so that it's more likely extraneous messages are associated with this test as opposed to leaking into the next test await new Promise((resolve) => setTimeout(resolve, 20)); await this.assertCalledWith(); diff --git a/test/startApp.test.ts b/test/startApp.test.ts index 297ed707..58247db7 100644 --- a/test/startApp.test.ts +++ b/test/startApp.test.ts @@ -43,27 +43,18 @@ describe('startApp', () => { let stream: TestEventStream; let coreApi: typeof coreTypes; let testDisposables: coreTypes.Disposable[] = []; - let originalEnv: NodeJS.ProcessEnv; - let originalCwd: string; before(async () => { - originalCwd = process.cwd(); - originalEnv = { ...process.env }; ({ stream, channel } = beforeEventHandlerSuite()); coreApi = await import('@azure/functions-core'); }); - after(() => { - Object.assign(process.env, originalEnv); - }); - afterEach(async () => { - await stream.afterEachEventHandlerTest(); coreApi.Disposable.from(...testDisposables).dispose(); testDisposables = []; - process.chdir(originalCwd); channel.appHookData = {}; channel.appLevelOnlyHookData = {}; + await stream.afterEachEventHandlerTest(); }); it('runs app start hooks in non-specialization scenario', async () => {