diff --git a/src/functions.ts b/src/functions.ts new file mode 100644 index 00000000..2494f019 --- /dev/null +++ b/src/functions.ts @@ -0,0 +1,98 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as express from 'express'; + +export interface HttpFunction { + // tslint:disable-next-line:no-any express interface. + (req: express.Request, res: express.Response): any; +} +export interface EventFunction { + // tslint:disable-next-line:no-any + (data: {}, context: Context): any; +} +export interface EventFunctionWithCallback { + // tslint:disable-next-line:no-any + (data: {}, context: Context, callback: Function): any; +} +export type HandlerFunction = + | HttpFunction + | EventFunction + | EventFunctionWithCallback; + +/** + * The Cloud Functions context object for the event. + * + * @link https://cloud.google.com/functions/docs/writing/background#function_parameters + */ +export interface CloudFunctionsContext { + /** + * A unique ID for the event. For example: "70172329041928". + */ + eventId?: string; + /** + * The date/time this event was created. For example: "2018-04-09T07:56:12.975Z" + * This will be formatted as ISO 8601. + */ + timestamp?: string; + /** + * The type of the event. For example: "google.pubsub.topic.publish". + */ + eventType?: string; + /** + * The resource that emitted the event. + */ + resource?: string; +} + +/** + * The CloudEvents v0.2 context object for the event. + * + * @link https://github.com/cloudevents/spec/blob/v0.2/spec.md#context-attributes + */ +export interface CloudEventsContext { + /** + * Type of occurrence which has happened. + */ + type?: string; + /** + * The version of the CloudEvents specification which the event uses. + */ + specversion?: string; + /** + * The event producer. + */ + source?: string; + /** + * ID of the event. + */ + id?: string; + /** + * Timestamp of when the event happened. + */ + time?: string; + /** + * A link to the schema that the event data adheres to. + */ + schemaurl?: string; + /** + * Content type of the event data. + */ + contenttype?: string; + + // tslint:disable-next-line:no-any CloudEvents extension attributes. + [key: string]: any; +} + +export type Context = CloudFunctionsContext | CloudEventsContext; diff --git a/src/index.ts b/src/index.ts index ed6ef53c..9c357efd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,12 +30,9 @@ import * as minimist from 'minimist'; import { resolve } from 'path'; -import { - ErrorHandler, - SignatureType, - getServer, - getUserFunction, -} from './invoker'; +import { getUserFunction } from './loader'; + +import { ErrorHandler, SignatureType, getServer } from './invoker'; // Supported command-line flags const FLAG = { diff --git a/src/invoker.ts b/src/invoker.ts index 74f800b6..59f16dbf 100644 --- a/src/invoker.ts +++ b/src/invoker.ts @@ -29,89 +29,14 @@ import * as onFinished from 'on-finished'; import { FUNCTION_STATUS_HEADER_FIELD } from './types'; import { logAndSendError } from './logger'; import { isBinaryCloudEvent } from './cloudevents'; - -/** - * The Cloud Functions context object for the event. - * - * @link https://cloud.google.com/functions/docs/writing/background#function_parameters - */ -export interface CloudFunctionsContext { - /** - * A unique ID for the event. For example: "70172329041928". - */ - eventId?: string; - /** - * The date/time this event was created. For example: "2018-04-09T07:56:12.975Z" - * This will be formatted as ISO 8601. - */ - timestamp?: string; - /** - * The type of the event. For example: "google.pubsub.topic.publish". - */ - eventType?: string; - /** - * The resource that emitted the event. - */ - resource?: string; -} - -/** - * The CloudEvents v0.2 context object for the event. - * - * @link https://github.com/cloudevents/spec/blob/v0.2/spec.md#context-attributes - */ -export interface CloudEventsContext { - /** - * Type of occurrence which has happened. - */ - type?: string; - /** - * The version of the CloudEvents specification which the event uses. - */ - specversion?: string; - /** - * The event producer. - */ - source?: string; - /** - * ID of the event. - */ - id?: string; - /** - * Timestamp of when the event happened. - */ - time?: string; - /** - * A link to the schema that the event data adheres to. - */ - schemaurl?: string; - /** - * Content type of the event data. - */ - contenttype?: string; - - // tslint:disable-next-line:no-any CloudEvents extension attributes. - [key: string]: any; -} - -export type Context = CloudFunctionsContext | CloudEventsContext; - -export interface HttpFunction { - // tslint:disable-next-line:no-any express interface. - (req: express.Request, res: express.Response): any; -} -export interface EventFunctionWithCallback { - // tslint:disable-next-line:no-any - (data: {}, context: Context, callback: Function): any; -} -export interface EventFunction { - // tslint:disable-next-line:no-any - (data: {}, context: Context): any; -} -export type HandlerFunction = - | HttpFunction - | EventFunction - | EventFunctionWithCallback; +import { + HttpFunction, + EventFunction, + EventFunctionWithCallback, + HandlerFunction, + CloudFunctionsContext, + CloudEventsContext, +} from './functions'; // We optionally annotate the express Request with a rawBody field. // Express leaves the Express namespace open to allow merging of new fields. @@ -141,95 +66,9 @@ function isHttpFunction( return functionSignatureType === SignatureType.HTTP; } -/** - * Returns user's function from function file. - * Returns null if function can't be retrieved. - * @return User's function or null. - */ -export function getUserFunction( - codeLocation: string, - functionTarget: string -): HandlerFunction | null { - try { - const functionModulePath = getFunctionModulePath(codeLocation); - if (functionModulePath === null) { - console.error('Provided code is not a loadable module.'); - return null; - } - - const functionModule = require(functionModulePath); - let userFunction = functionTarget - .split('.') - .reduce((code, functionTargetPart) => { - if (typeof code === 'undefined') { - return undefined; - } else { - return code[functionTargetPart]; - } - }, functionModule); - - // TODO: do we want 'function' fallback? - if (typeof userFunction === 'undefined') { - if (functionModule.hasOwnProperty('function')) { - userFunction = functionModule['function']; - } else { - console.error( - `Function '${functionTarget}' is not defined in the provided ` + - 'module.\nDid you specify the correct target function to execute?' - ); - return null; - } - } - - if (typeof userFunction !== 'function') { - console.error( - `'${functionTarget}' needs to be of type function. Got: ` + - `${typeof userFunction}` - ); - return null; - } - - return userFunction as HandlerFunction; - } catch (ex) { - let additionalHint: string; - // TODO: this should be done based on ex.code rather than string matching. - if (ex.stack && ex.stack.includes('Cannot find module')) { - additionalHint = - 'Did you list all required modules in the package.json ' + - 'dependencies?\n'; - } else { - additionalHint = 'Is there a syntax error in your code?\n'; - } - console.error( - `Provided module can't be loaded.\n${additionalHint}` + - `Detailed stack trace: ${ex.stack}` - ); - return null; - } -} - // Response object for the most recent request. let latestRes: express.Response | null = null; -/** - * Returns resolved path to the module containing the user function. - * Returns null if the module can not be identified. - * @param codeLocation Directory with user's code. - * @return Resolved path or null. - */ -function getFunctionModulePath(codeLocation: string): string | null { - let path: string | null = null; - try { - path = require.resolve(codeLocation); - } catch (ex) { - try { - // TODO: Decide if we want to keep this fallback. - path = require.resolve(codeLocation + '/function.js'); - } catch (ex) {} - } - return path; -} - /** * Sends back a response to the incoming request. * @param result Output from function execution. diff --git a/src/loader.ts b/src/loader.ts new file mode 100644 index 00000000..cda65891 --- /dev/null +++ b/src/loader.ts @@ -0,0 +1,110 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// loader.ts +/** + * This package contains the logic to load user's function. + * @packageDocumentation + */ + +/** + * Import function signature type's definition. + */ +import { HandlerFunction } from './functions'; + +/** + * Returns user's function from function file. + * Returns null if function can't be retrieved. + * @return User's function or null. + */ +export function getUserFunction( + codeLocation: string, + functionTarget: string +): HandlerFunction | null { + try { + const functionModulePath = getFunctionModulePath(codeLocation); + if (functionModulePath === null) { + console.error('Provided code is not a loadable module.'); + return null; + } + + const functionModule = require(functionModulePath); + let userFunction = functionTarget + .split('.') + .reduce((code, functionTargetPart) => { + if (typeof code === 'undefined') { + return undefined; + } else { + return code[functionTargetPart]; + } + }, functionModule); + + // TODO: do we want 'function' fallback? + if (typeof userFunction === 'undefined') { + if (functionModule.hasOwnProperty('function')) { + userFunction = functionModule['function']; + } else { + console.error( + `Function '${functionTarget}' is not defined in the provided ` + + 'module.\nDid you specify the correct target function to execute?' + ); + return null; + } + } + + if (typeof userFunction !== 'function') { + console.error( + `'${functionTarget}' needs to be of type function. Got: ` + + `${typeof userFunction}` + ); + return null; + } + + return userFunction as HandlerFunction; + } catch (ex) { + let additionalHint: string; + // TODO: this should be done based on ex.code rather than string matching. + if (ex.stack && ex.stack.includes('Cannot find module')) { + additionalHint = + 'Did you list all required modules in the package.json ' + + 'dependencies?\n'; + } else { + additionalHint = 'Is there a syntax error in your code?\n'; + } + console.error( + `Provided module can't be loaded.\n${additionalHint}` + + `Detailed stack trace: ${ex.stack}` + ); + return null; + } +} + +/** + * Returns resolved path to the module containing the user function. + * Returns null if the module can not be identified. + * @param codeLocation Directory with user's code. + * @return Resolved path or null. + */ +function getFunctionModulePath(codeLocation: string): string | null { + let path: string | null = null; + try { + path = require.resolve(codeLocation); + } catch (ex) { + try { + // TODO: Decide if we want to keep this fallback. + path = require.resolve(codeLocation + '/function.js'); + } catch (ex) {} + } + return path; +} diff --git a/test/data/with_main/foo.js b/test/data/with_main/foo.js new file mode 100644 index 00000000..e3699a83 --- /dev/null +++ b/test/data/with_main/foo.js @@ -0,0 +1,24 @@ +/** + * Test HTTP function to test function loading. + * + * @param {!Object} req request context. + * @param {!Object} res response context. + */ +function testHttpFunction (res, req) { + return 'PASS' +}; + +/** + * Test event function to test function loading. + * + * @param {!Object} data event payload. + * @param {!Object} context event metadata. + */ +function testEventFunction (data, context) { + return 'PASS'; +}; + +module.exports = { + testHttpFunction, + testEventFunction, +} diff --git a/test/data/with_main/package.json b/test/data/with_main/package.json new file mode 100644 index 00000000..438495d6 --- /dev/null +++ b/test/data/with_main/package.json @@ -0,0 +1,3 @@ +{ + "main": "foo.js" +} \ No newline at end of file diff --git a/test/data/without_main/function.js b/test/data/without_main/function.js new file mode 100644 index 00000000..e3699a83 --- /dev/null +++ b/test/data/without_main/function.js @@ -0,0 +1,24 @@ +/** + * Test HTTP function to test function loading. + * + * @param {!Object} req request context. + * @param {!Object} res response context. + */ +function testHttpFunction (res, req) { + return 'PASS' +}; + +/** + * Test event function to test function loading. + * + * @param {!Object} data event payload. + * @param {!Object} context event metadata. + */ +function testEventFunction (data, context) { + return 'PASS'; +}; + +module.exports = { + testHttpFunction, + testEventFunction, +} diff --git a/test/function.js b/test/function.js deleted file mode 100644 index e1c222d0..00000000 --- a/test/function.js +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2019 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -exports.foo = (data, context) => { - // Message used for function load verification in test. - return 'Hello from foo'; -}; diff --git a/test/invoker.ts b/test/invoker.ts index e8659b56..e9546e80 100644 --- a/test/invoker.ts +++ b/test/invoker.ts @@ -14,20 +14,10 @@ import * as assert from 'assert'; import * as express from 'express'; +import * as functions from '../src/functions'; import * as invoker from '../src/invoker'; import * as supertest from 'supertest'; -describe('loading function', () => { - it('should load the function', () => { - const loadedFunction = invoker.getUserFunction( - process.cwd() + '/test/function', - 'foo' - ) as invoker.EventFunction; - const returned = loadedFunction({}, {}); - assert.strictEqual(returned, 'Hello from foo'); - }); -}); - describe('request to HTTP function', () => { interface TestData { name: string; @@ -137,11 +127,14 @@ describe('GCF event request to event function', () => { testData.forEach(test => { it(`should receive data and context from ${test.name}`, async () => { let receivedData: {} | null = null; - let receivedContext: invoker.CloudFunctionsContext | null = null; - const server = invoker.getServer((data: {}, context: invoker.Context) => { - receivedData = data; - receivedContext = context as invoker.CloudFunctionsContext; - }, invoker.SignatureType.EVENT); + let receivedContext: functions.CloudFunctionsContext | null = null; + const server = invoker.getServer( + (data: {}, context: functions.Context) => { + receivedData = data; + receivedContext = context as functions.CloudFunctionsContext; + }, + invoker.SignatureType.EVENT + ); await supertest(server) .post('/') .send(test.body) @@ -200,11 +193,14 @@ describe('CloudEvents request to event function', () => { testData.forEach(test => { it(`should receive data and context from ${test.name}`, async () => { let receivedData: {} | null = null; - let receivedContext: invoker.CloudEventsContext | null = null; - const server = invoker.getServer((data: {}, context: invoker.Context) => { - receivedData = data; - receivedContext = context as invoker.CloudEventsContext; - }, invoker.SignatureType.EVENT); + let receivedContext: functions.CloudEventsContext | null = null; + const server = invoker.getServer( + (data: {}, context: functions.Context) => { + receivedData = data; + receivedContext = context as functions.CloudEventsContext; + }, + invoker.SignatureType.EVENT + ); await supertest(server) .post('/') .set(test.headers) diff --git a/test/loader.ts b/test/loader.ts new file mode 100644 index 00000000..38b611b1 --- /dev/null +++ b/test/loader.ts @@ -0,0 +1,50 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import * as express from 'express'; +import * as functions from '../src/functions'; +import * as loader from '../src/loader'; + +describe('loading function', () => { + interface TestData { + name: string; + codeLocation: string; + target: string; + } + + const testData: TestData[] = [ + { + name: 'function without "main" in package.json', + codeLocation: '/test/data/without_main', + target: 'testEventFunction', + }, + { + name: 'function with "main" in package.json', + codeLocation: '/test/data/with_main', + target: 'testEventFunction', + }, + ]; + + for (const test of testData) { + it(`should load ${test.name}`, () => { + const loadedFunction = loader.getUserFunction( + process.cwd() + test.codeLocation, + test.target + ) as functions.HttpFunction; + const returned = loadedFunction(express.request, express.response); + assert.strictEqual(returned, 'PASS'); + }); + } +});