diff --git a/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto b/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto index 58dd7eb7..fbb585b2 100644 --- a/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto +++ b/azure-functions-language-worker-protobuf/src/proto/FunctionRpc.proto @@ -11,6 +11,7 @@ package AzureFunctionsRpcMessages; import "google/protobuf/duration.proto"; import "identity/ClaimsIdentityRpc.proto"; +import "shared/NullableTypes.proto"; // Interface exported by the server. service FunctionRpc { @@ -375,6 +376,44 @@ message RpcException { string message = 2; } +// Http cookie type. Note that only name and value are used for Http requests +message RpcHttpCookie { + // Enum that lets servers require that a cookie shouoldn't be sent with cross-site requests + enum SameSite { + None = 0; + Lax = 1; + Strict = 2; + } + + // Cookie name + string name = 1; + + // Cookie value + string value = 2; + + // Specifies allowed hosts to receive the cookie + NullableString domain = 3; + + // Specifies URL path that must exist in the requested URL + NullableString path = 4; + + // Sets the cookie to expire at a specific date instead of when the client closes. + // It is generally recommended that you use "Max-Age" over "Expires". + NullableTimestamp expires = 5; + + // Sets the cookie to only be sent with an encrypted request + NullableBool secure = 6; + + // Sets the cookie to be inaccessible to JavaScript's Document.cookie API + NullableBool http_only = 7; + + // Allows servers to assert that a cookie ought not to be sent along with cross-site requests + SameSite same_site = 8; + + // Number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. + NullableDouble max_age = 9; +} + // TODO - solidify this or remove it message RpcHttp { string method = 1; @@ -387,4 +426,5 @@ message RpcHttp { bool enable_content_negotiation= 16; TypedData rawBody = 17; repeated RpcClaimsIdentity identities = 18; + repeated RpcHttpCookie cookies = 19; } diff --git a/azure-functions-language-worker-protobuf/src/proto/identity/ClaimsIdentityRpc.proto b/azure-functions-language-worker-protobuf/src/proto/identity/ClaimsIdentityRpc.proto index 01af5456..b9219615 100644 --- a/azure-functions-language-worker-protobuf/src/proto/identity/ClaimsIdentityRpc.proto +++ b/azure-functions-language-worker-protobuf/src/proto/identity/ClaimsIdentityRpc.proto @@ -1,7 +1,7 @@ syntax = "proto3"; // protobuf vscode extension: https://marketplace.visualstudio.com/items?itemName=zxh404.vscode-proto3 -import "shared/NullableString.proto"; +import "shared/NullableTypes.proto"; // Light-weight representation of a .NET System.Security.Claims.ClaimsIdentity object. // This is the same serialization as found in EasyAuth, and needs to be kept in sync with diff --git a/azure-functions-language-worker-protobuf/src/proto/shared/NullableString.proto b/azure-functions-language-worker-protobuf/src/proto/shared/NullableString.proto deleted file mode 100644 index 7fd81b4d..00000000 --- a/azure-functions-language-worker-protobuf/src/proto/shared/NullableString.proto +++ /dev/null @@ -1,8 +0,0 @@ -syntax = "proto3"; -// protobuf vscode extension: https://marketplace.visualstudio.com/items?itemName=zxh404.vscode-proto3 - -message NullableString { - oneof string { - string value = 1; - } -} diff --git a/azure-functions-language-worker-protobuf/src/proto/shared/NullableTypes.proto b/azure-functions-language-worker-protobuf/src/proto/shared/NullableTypes.proto new file mode 100644 index 00000000..51786101 --- /dev/null +++ b/azure-functions-language-worker-protobuf/src/proto/shared/NullableTypes.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; +// protobuf vscode extension: https://marketplace.visualstudio.com/items?itemName=zxh404.vscode-proto3 + +import "google/protobuf/timestamp.proto"; + +message NullableString { + oneof string { + string value = 1; + } +} + +message NullableDouble { + oneof double { + double value = 1; + } +} + +message NullableBool { + oneof bool { + bool value = 1; + } +} + +message NullableTimestamp { + oneof timestamp { + google.protobuf.Timestamp value = 1; + } +} diff --git a/package-lock.json b/package-lock.json index 2690bcf8..8b337193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "azure-functions-nodejs-worker", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/Context.ts b/src/Context.ts index 2be0dc0d..da0af967 100644 --- a/src/Context.ts +++ b/src/Context.ts @@ -1,5 +1,5 @@ import { FunctionInfo } from './FunctionInfo'; -import { fromRpcHttp, fromTypedData, getNormalizedBindingData, getBindingDefinitions } from './Converters'; +import { fromRpcHttp, fromTypedData, getNormalizedBindingData, getBindingDefinitions } from './converters'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import { Request, RequestProperties } from './http/Request'; import { Response } from './http/Response'; @@ -59,7 +59,8 @@ class InvocationContext implements Context { this.bindings = {}; let _done = false; let _promise = false; - + + // Log message that is tied to function invocation this.log = Object.assign( (...args: any[]) => logWithAsyncCheck(_done, logCallback, LogLevel.Information, executionContext, ...args), { @@ -86,6 +87,7 @@ class InvocationContext implements Context { } _done = true; + // Allow HTTP response from context.res if HTTP response is not defined from the context.bindings object if (info.httpOutputName && this.res && this.bindings[info.httpOutputName] === undefined) { this.bindings[info.httpOutputName] = this.res; } diff --git a/src/Converters.ts b/src/Converters.ts deleted file mode 100644 index e3e1ab37..00000000 --- a/src/Converters.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { AzureFunctionsRpcMessages as rpc, INullableString } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { FunctionInfo } from './FunctionInfo'; -import { RequestProperties } from './http/Request'; -import { Dict } from '../src/Context'; -import { BindingDefinition, HttpMethod } from './public/Interfaces'; - -type BindingDirection = 'in' | 'out' | 'inout' | undefined; - -export function fromRpcHttp(rpcHttp: rpc.IRpcHttp): RequestProperties { - const httpContext: RequestProperties = { - method: rpcHttp.method, - url: rpcHttp.url, - originalUrl: rpcHttp.url, - headers: >rpcHttp.headers, - query: >rpcHttp.query, - params: >rpcHttp.params, - body: fromTypedData(rpcHttp.body), - rawBody: fromTypedData(rpcHttp.rawBody, false), - }; - - return httpContext; -} - -export function fromNullableString(nullableString: INullableString | null | undefined): string | undefined { - if (!nullableString || !nullableString.value) { - return undefined; - } else { - return nullableString.value; - } -} - -export function toRpcHttp(inputMessage): rpc.ITypedData { - let httpMessage: rpc.IRpcHttp = inputMessage; - httpMessage.headers = toRpcHttpHeaders(inputMessage.headers); - let status = inputMessage.statusCode || inputMessage.status; - httpMessage.statusCode = status && status.toString(); - httpMessage.body = toTypedData(inputMessage.body); - return { http: httpMessage }; -} - -export function toRpcHttpHeaders(inputHeaders: rpc.ITypedData) { - let rpcHttpHeaders: {[key: string]: string} = {}; - for (let key in inputHeaders) { - if (inputHeaders[key] != null) { - rpcHttpHeaders[key] = inputHeaders[key].toString(); - } - } - return rpcHttpHeaders; -} - -export function fromTypedData(typedData?: rpc.ITypedData, convertStringToJson: boolean = 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); - } -} - -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)) { - let 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) }; - } -} - -export function getBindingDefinitions(info: FunctionInfo): BindingDefinition[] { - let 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): Dict { - let bindingData: Dict = { - 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 { - let 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 -function convertKeysToCamelCase(obj: any) { - var output = {}; - for (var key in obj) { - let value = fromTypedData(obj[key]) || obj[key]; - let 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/FunctionInfo.ts b/src/FunctionInfo.ts index 7f4d99e7..a3c487b2 100644 --- a/src/FunctionInfo.ts +++ b/src/FunctionInfo.ts @@ -1,5 +1,7 @@ import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; -import { toTypedData, toRpcHttp } from './Converters'; +import { toTypedData, toRpcHttp } from './converters'; + +const returnBindingKey = "$return"; export class FunctionInfo { public name: string; @@ -35,4 +37,9 @@ export class FunctionInfo { }); } } + + /** Return output binding details on the special key "$return" output binding */ + public getReturnBinding() { + return this.outputBindings[returnBindingKey]; + } } diff --git a/src/WorkerChannel.ts b/src/WorkerChannel.ts index ac9684f1..e4690338 100644 --- a/src/WorkerChannel.ts +++ b/src/WorkerChannel.ts @@ -4,7 +4,7 @@ import Status = rpc.StatusResult.Status; import { IFunctionLoader } from './FunctionLoader'; import { CreateContextAndInputs, LogCallback, ResultCallback } from './Context'; import { IEventStream } from './GrpcService'; -import { toTypedData } from './Converters'; +import { toTypedData } from './converters'; import { systemError, systemLog } from './utils/Logger'; /** @@ -137,18 +137,24 @@ export class WorkerChannel implements IWorkerChannel { invocationId: msg.invocationId, result: this.getStatus(err) } - if (result) { - if (result.return) { - response.returnValue = toTypedData(result.return); - } - if (result.bindings) { - response.outputData = Object.keys(info.outputBindings) - .filter(key => result.bindings[key] !== undefined) - .map(key => { - name: key, - data: info.outputBindings[key].converter(result.bindings[key]) - }); + + try { + if (result) { + if (result.return) { + let returnBinding = info.getReturnBinding(); + response.returnValue = returnBinding ? returnBinding.converter(result.return) : toTypedData(result.return); + } + if (result.bindings) { + response.outputData = Object.keys(info.outputBindings) + .filter(key => result.bindings[key] !== undefined) + .map(key => { + name: key, + data: info.outputBindings[key].converter(result.bindings[key]) + }); + } } + } catch (e) { + response.result = this.getStatus(e) } this._eventStream.write({ diff --git a/src/converters/BindingConverters.ts b/src/converters/BindingConverters.ts new file mode 100644 index 00000000..35e352bd --- /dev/null +++ b/src/converters/BindingConverters.ts @@ -0,0 +1,58 @@ +import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; +import { FunctionInfo } from '../FunctionInfo'; +import { Dict } from '../Context'; +import { BindingDefinition } from '../public/Interfaces'; +import { fromTypedData } from './RpcConverters'; + +type BindingDirection = 'in' | 'out' | 'inout' | undefined; + +export function getBindingDefinitions(info: FunctionInfo): BindingDefinition[] { + let 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): Dict { + let bindingData: Dict = { + 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 { + let 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 +function convertKeysToCamelCase(obj: any) { + var output = {}; + for (var key in obj) { + let value = fromTypedData(obj[key]) || obj[key]; + let 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 new file mode 100644 index 00000000..1dc68142 --- /dev/null +++ b/src/converters/RpcConverters.ts @@ -0,0 +1,164 @@ +import { + AzureFunctionsRpcMessages as rpc, + INullableString, + INullableBool, + INullableDouble, + INullableTimestamp +} from '../../azure-functions-language-worker-protobuf/src/rpc'; + +/** + * 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: boolean = 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); + } +} + +/** + * 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)) { + let 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 Error(`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 Error(`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 Error(`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 Error(`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 { + let timeInMilliseconds = (typeof dateTime === "number") ? dateTime : dateTime.getTime(); + + if (timeInMilliseconds && timeInMilliseconds >= 0) { + return { + value: { + seconds: Math.round(timeInMilliseconds / 1000) + } + } + } + } catch(e) { + throw new Error(`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 new file mode 100644 index 00000000..5d31afdf --- /dev/null +++ b/src/converters/RpcHttpConverters.ts @@ -0,0 +1,104 @@ +import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc'; +import { HttpMethod, Cookie } from '../public/Interfaces'; +import { RequestProperties } from '../http/Request'; +import { Dict } from '../Context'; +import { + fromTypedData, + toTypedData, + toRpcString, + toNullableString, + toNullableBool, + toNullableDouble, + toNullableTimestamp +} from './RpcConverters'; + +/** + * Converts 'IRpcHttp' input from the RPC layer to a JavaScript object. + * @param rpcHttp RPC layer representation of an HTTP request + */ +export function fromRpcHttp(rpcHttp: rpc.IRpcHttp): RequestProperties { + const httpContext: RequestProperties = { + method: rpcHttp.method, + url: rpcHttp.url, + originalUrl: rpcHttp.url, + headers: >rpcHttp.headers, + query: >rpcHttp.query, + params: >rpcHttp.params, + body: fromTypedData(rpcHttp.body), + rawBody: fromTypedData(rpcHttp.rawBody, false), + }; + + return httpContext; +} + +/** + * 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 { + let httpMessage: rpc.IRpcHttp = inputMessage; + httpMessage.headers = toRpcHttpHeaders(inputMessage.headers); + httpMessage.cookies = toRpcHttpCookieList(inputMessage.cookies || []); + let status = inputMessage.statusCode || 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) { + let rpcHttpHeaders: {[key: string]: string} = {}; + for (let 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[] { + let 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) { + if (inputCookie.sameSite.toLocaleLowerCase() === "lax") { + rpcSameSite = rpc.RpcHttpCookie.SameSite.Lax; + } else if (inputCookie.sameSite.toLocaleLowerCase() === "strict") { + rpcSameSite = rpc.RpcHttpCookie.SameSite.Strict; + } + } + + 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; +} \ No newline at end of file diff --git a/src/converters/index.ts b/src/converters/index.ts new file mode 100644 index 00000000..1f67d807 --- /dev/null +++ b/src/converters/index.ts @@ -0,0 +1,3 @@ +export * from './RpcConverters'; +export * from './BindingConverters'; +export * from './RpcHttpConverters'; \ No newline at end of file diff --git a/src/http/Response.ts b/src/http/Response.ts index 2b1964e2..b32cb45c 100644 --- a/src/http/Response.ts +++ b/src/http/Response.ts @@ -1,3 +1,5 @@ +import { Cookie } from '../public/Interfaces'; + // Copyright (c) .NET Foundation. All rights thiserved. // Licensed under the MIT License. See License.txt in the project root for license information. interface IResponse { @@ -5,6 +7,7 @@ interface IResponse { headers: { [key: string]: any; }; + cookies: Cookie[]; body?: any; get(field: string): any; set(field: string, val: any): IResponse; @@ -15,6 +18,7 @@ interface IResponse { export class Response implements IResponse { statusCode?: string | number; headers: {[key:string]: any} = {}; + cookies: Cookie[] = []; body?: any; enableContentNegotiation?: boolean; [key:string]: any; diff --git a/src/public/Interfaces.ts b/src/public/Interfaces.ts index 768d73bd..eeed4b09 100644 --- a/src/public/Interfaces.ts +++ b/src/public/Interfaces.ts @@ -102,6 +102,43 @@ export interface HttpRequest { */ export type HttpMethod = "GET" | "POST" | "DELETE"| "HEAD"| "PATCH"| "PUT" | "OPTIONS" | "TRACE" | "CONNECT"; +/** + * 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" | 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. diff --git a/test/ConvertersTests.ts b/test/BindingConvertersTests.ts similarity index 98% rename from test/ConvertersTests.ts rename to test/BindingConvertersTests.ts index 456d7605..5ede4ca6 100644 --- a/test/ConvertersTests.ts +++ b/test/BindingConvertersTests.ts @@ -1,11 +1,11 @@ -import { getNormalizedBindingData, toRpcHttp, getBindingDefinitions } from '../src/Converters'; +import { getNormalizedBindingData, toRpcHttp, getBindingDefinitions } from '../src/converters'; import { FunctionInfo } from '../src/FunctionInfo'; import { expect } from 'chai'; import * as sinon from 'sinon'; import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; import 'mocha'; -describe('Converters', () => { +describe('Binding Converters', () => { it('normalizes binding trigger metadata for HTTP', () => { var mockRequest: rpc.ITypedData = toRpcHttp({ url: "https://mock"}); var triggerDataMock: { [k: string]: rpc.ITypedData } = { diff --git a/test/FunctionInfoTests.ts b/test/FunctionInfoTests.ts new file mode 100644 index 00000000..12553cda --- /dev/null +++ b/test/FunctionInfoTests.ts @@ -0,0 +1,51 @@ +import { FunctionInfo } from '../src/FunctionInfo'; +import { Cookie } from "../types/public/Interfaces"; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; +import 'mocha'; + +describe('FunctionInfo', () => { + /** NullableBool */ + it('gets $return output binding converter for http', () => { + let metadata: rpc.IRpcFunctionMetadata = { + bindings: { + req: { + type: "http", + direction: 0, + dataType: 1 + }, + $return: { + type: "http", + direction: 1, + dataType: 1 + } + } + }; + + let funcInfo = new FunctionInfo(metadata); + console.log(funcInfo); + expect(funcInfo.getReturnBinding().converter.name).to.equal("toRpcHttp"); + }); + + it('gets $return output binding converter for TypedData', () => { + let metadata: rpc.IRpcFunctionMetadata = { + bindings: { + input: { + type: "queue", + direction: 0, + dataType: 1 + }, + $return: { + type: "queue", + direction: 1, + dataType: 1 + } + } + }; + + let funcInfo = new FunctionInfo(metadata); + console.log(funcInfo); + expect(funcInfo.getReturnBinding().converter.name).to.equal("toTypedData"); + }); +}) diff --git a/test/InterfacesTest.ts b/test/InterfacesTest.ts index df8527db..d6ead28a 100644 --- a/test/InterfacesTest.ts +++ b/test/InterfacesTest.ts @@ -1,5 +1,5 @@ // Test typescript interfaces for ts compliation errors -import { AzureFunction, Context, HttpRequest, HttpMethod } from "../types/public/Interfaces"; +import { AzureFunction, Context, HttpRequest, HttpMethod, Cookie } from "../types/public/Interfaces"; const get: HttpMethod = "GET"; const runHttp: AzureFunction = async function (context: Context, req: HttpRequest) { @@ -50,6 +50,20 @@ const runFunction: AzureFunction = async function(context: Context) { return "Ran function"; } +const cookieFunction: AzureFunction = async function(context: Context) { + let cookies: Cookie[] = [ + { + name: "cookiename", + value: "cookievalue", + expires: Date.now() + } + ]; + context.res = { + cookies, + body: "just a normal body" + }; +} + 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); @@ -61,4 +75,4 @@ const returnWithContextDone: AzureFunction = function (context: Context, req: Ht context.done(null, { myOutput: { text: 'hello there, world', noNumber: true }}); } -export { runHttp, runHttpReturn, runServiceBus, runFunction, runHttpWithQueue, returnWithContextDone }; \ No newline at end of file +export { runHttp, cookieFunction, runHttpReturn, runServiceBus, runFunction, runHttpWithQueue, returnWithContextDone }; \ No newline at end of file diff --git a/test/RpcConvertersTests.ts b/test/RpcConvertersTests.ts new file mode 100644 index 00000000..e6d9c885 --- /dev/null +++ b/test/RpcConvertersTests.ts @@ -0,0 +1,134 @@ +import { toNullableBool, toNullableString, toNullableDouble, toNullableTimestamp } from '../src/converters'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; +import 'mocha'; + +describe('Rpc Converters', () => { + /** NullableBool */ + it('converts true to NullableBool', () => { + let nullable = toNullableBool(true, "test"); + expect(nullable && nullable.value).to.equal(true); + }); + + it('converts false to NullableBool', () => { + let 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('does not converts null to NullableBool', () => { + let nullable = toNullableBool(null, "test"); + expect(nullable && nullable.value).to.be.undefined; + }); + + /** NullableString */ + it('converts string to NullableString', () => { + let input = "hello"; + let nullable = toNullableString(input, "test"); + expect(nullable && nullable.value).to.equal(input); + }); + + it('converts empty string to NullableString', () => { + let input = ""; + let 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', () => { + let nullable = toNullableString(null, "test"); + expect(nullable && nullable.value).to.be.undefined; + }); + + /** NullableDouble */ + it('converts number to NullableDouble', () => { + let input = 1234567; + let nullable = toNullableDouble(input, "test"); + expect(nullable && nullable.value).to.equal(input); + }); + + it('converts 0 to NullableDouble', () => { + let input = 0; + let nullable = toNullableDouble(input, "test"); + expect(nullable && nullable.value).to.equal(input); + }); + + it('converts negative number to NullableDouble', () => { + let input = -11234567; + let nullable = toNullableDouble(input, "test"); + expect(nullable && nullable.value).to.equal(input); + }); + + it('converts numeric string to NullableDouble', () => { + let input = "1234567"; + let nullable = toNullableDouble(input, "test"); + expect(nullable && nullable.value).to.equal(1234567); + }); + + it('converts float string to NullableDouble', () => { + let input = "1234567.002"; + let 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', () => { + let nullable = toNullableDouble(undefined, "test"); + expect(nullable && nullable.value).to.be.undefined; + }); + + /** NullableTimestamp */ + it('converts Date to NullableTimestamp', () => { + let input = new Date("1/2/2014") + let nullable = toNullableTimestamp(input, "test"); + let secondInput = Math.round((input).getTime() / 1000); + expect(nullable && nullable.value && nullable.value.seconds).to.equal(secondInput); + }); + + it('converts Date.now to NullableTimestamp', () => { + let input = Date.now(); + let nullable = toNullableTimestamp(input, "test"); + let secondInput = Math.round(input / 1000); + expect(nullable && nullable.value && nullable.value.seconds).to.equal(secondInput); + }); + + it('converts milliseconds to NullableTimestamp', () => { + let input = Date.now(); + let nullable = toNullableTimestamp(input, "test"); + let 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', () => { + let nullable = toNullableTimestamp(undefined, "test"); + expect(nullable && nullable.value).to.be.undefined; + }); +}) \ No newline at end of file diff --git a/test/RpcHttpConverters.ts b/test/RpcHttpConverters.ts new file mode 100644 index 00000000..016412b6 --- /dev/null +++ b/test/RpcHttpConverters.ts @@ -0,0 +1,75 @@ +import { toRpcHttpCookieList } from '../src/converters'; +import { Cookie } from "../types/public/Interfaces"; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc'; +import 'mocha'; + +describe('Rpc Converters', () => { + /** NullableBool */ + it('converts http cookies', () => { + let 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') + } + ]; + + let 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('throws on invalid input', () => { + expect(() => { + let 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(""); + }); +}) diff --git a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs index c8ca2847..704221ce 100644 --- a/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs +++ b/test/end-to-end/Azure.Functions.NodejsWorker.E2E/Azure.Functions.NodejsWorker.E2E/HttpEndToEndTests.cs @@ -27,5 +27,11 @@ public async Task HttpTriggerTests(string functionName, string queryString, Http // TODO: Verify exception on 500 after https://github.com/Azure/azure-functions-host/issues/3589 Assert.True(await Utilities.InvokeHttpTrigger(functionName, queryString, expectedStatusCode, expectedMessage)); } + + [Fact(Skip = "Not yet enabled.")] + public async Task HttpTriggerWithCookieTests() + { + Assert.True(await Utilities.InvokeHttpTrigger("HttpTriggerSetsCookie", "", HttpStatusCode.OK, "mycookie=myvalue, mycookie2=myvalue2")); + } } } \ No newline at end of file diff --git a/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/function.json b/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/function.json new file mode 100644 index 00000000..4ef0fff9 --- /dev/null +++ b/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/function.json @@ -0,0 +1,20 @@ +{ + "disabled": false, + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} \ No newline at end of file diff --git a/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js b/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js new file mode 100644 index 00000000..c89a76fc --- /dev/null +++ b/test/end-to-end/testFunctionApp/HttpTriggerSetsCookie/index.js @@ -0,0 +1,25 @@ +module.exports = async function (context, req) { + context.log('JavaScript HTTP trigger function processed a request.'); + + return { + cookies: [ + { + name: "mycookie", + value: "myvalue", + maxAge: 200000 + }, + { + name: "mycookie2", + value: "myvalue2", + path: "/", + maxAge: "200000" + }, + { + name: "mycookie3-expires", + value: "myvalue3-expires", + maxAge: 0 + } + ], + body: JSON.stringify(req.headers["cookie"]) + } +}; \ No newline at end of file diff --git a/types/public/Interfaces.d.ts b/types/public/Interfaces.d.ts index 65f85435..0e529c2d 100644 --- a/types/public/Interfaces.d.ts +++ b/types/public/Interfaces.d.ts @@ -110,6 +110,33 @@ export interface HttpRequest { * Possible values for an HTTP request method. */ export declare type HttpMethod = "GET" | "POST" | "DELETE" | "HEAD" | "PATCH" | "PUT" | "OPTIONS" | "TRACE" | "CONNECT"; +/** + * 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" | 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.