diff --git a/src/__tests__/js-resolvers.test.ts b/src/__tests__/js-resolvers.test.ts new file mode 100644 index 00000000..31e75bd2 --- /dev/null +++ b/src/__tests__/js-resolvers.test.ts @@ -0,0 +1,140 @@ +import { Api } from '../resources/Api'; +import { JsResolver } from '../resources/JsResolver'; +import * as given from './given'; +import fs from 'fs'; + +const plugin = given.plugin(); + +describe('Mapping Templates', () => { + let mock: jest.SpyInstance; + let mockEists: jest.SpyInstance; + + beforeEach(() => { + mock = jest + .spyOn(fs, 'readFileSync') + .mockImplementation( + (path) => `Content of ${`${path}`.replace(/\\/g, '/')}`, + ); + mockEists = jest.spyOn(fs, 'existsSync').mockReturnValue(true); + }); + + afterEach(() => { + mock.mockRestore(); + mockEists.mockRestore(); + }); + + it('should substitute variables', () => { + const api = new Api(given.appSyncConfig(), plugin); + const mapping = new JsResolver(api, { + path: 'foo.vtl', + substitutions: { + foo: 'bar', + var: { Ref: 'MyReference' }, + }, + }); + const template = `const foo = '#foo#'; + const var = '#var#'; + const unknonw = '#unknown#'`; + expect(mapping.processTemplateSubstitutions(template)) + .toMatchInlineSnapshot(` + Object { + "Fn::Join": Array [ + "", + Array [ + "const foo = '", + Object { + "Fn::Sub": Array [ + "\${foo}", + Object { + "foo": "bar", + }, + ], + }, + "'; + const var = '", + Object { + "Fn::Sub": Array [ + "\${var}", + Object { + "var": Object { + "Ref": "MyReference", + }, + }, + ], + }, + "'; + const unknonw = '#unknown#'", + ], + ], + } + `); + }); + + it('should substitute variables and use defaults', () => { + const api = new Api( + given.appSyncConfig({ + substitutions: { + foo: 'bar', + var: 'bizz', + }, + }), + plugin, + ); + const mapping = new JsResolver(api, { + path: 'foo.vtl', + substitutions: { + foo: 'fuzz', + }, + }); + const template = `const foo = '#foo#'; + const var = '#var#';`; + expect(mapping.processTemplateSubstitutions(template)) + .toMatchInlineSnapshot(` + Object { + "Fn::Join": Array [ + "", + Array [ + "const foo = '", + Object { + "Fn::Sub": Array [ + "\${foo}", + Object { + "foo": "fuzz", + }, + ], + }, + "'; + const var = '", + Object { + "Fn::Sub": Array [ + "\${var}", + Object { + "var": "bizz", + }, + ], + }, + "';", + ], + ], + } + `); + }); + + it('should fail if template is missing', () => { + mockEists = jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const api = new Api(given.appSyncConfig(), plugin); + const mapping = new JsResolver(api, { + path: 'foo.vtl', + substitutions: { + foo: 'bar', + var: { Ref: 'MyReference' }, + }, + }); + + expect(function () { + mapping.compile(); + }).toThrowErrorMatchingInlineSnapshot( + `"The resolver handler file 'foo.vtl' does not exist"`, + ); + }); +}); diff --git a/src/__tests__/mapping-templates.test.ts b/src/__tests__/mapping-templates.test.ts index a3da3d12..9870b087 100644 --- a/src/__tests__/mapping-templates.test.ts +++ b/src/__tests__/mapping-templates.test.ts @@ -23,7 +23,7 @@ describe('Mapping Templates', () => { mockEists.mockRestore(); }); - it('should substritute variables', () => { + it('should substitute variables', () => { const api = new Api(given.appSyncConfig(), plugin); const mapping = new MappingTemplate(api, { path: 'foo.vtl', @@ -67,7 +67,7 @@ describe('Mapping Templates', () => { `); }); - it('should substritute variables and use defaults', () => { + it('should substitute variables and use defaults', () => { const api = new Api( given.appSyncConfig({ substitutions: { diff --git a/src/resources/JsResolver.ts b/src/resources/JsResolver.ts new file mode 100644 index 00000000..3e1bca8d --- /dev/null +++ b/src/resources/JsResolver.ts @@ -0,0 +1,85 @@ +import { IntrinsicFunction } from '../types/cloudFormation'; +import fs from 'fs'; +import { Substitutions } from '../types/plugin'; +import { Api } from './Api'; + +type JsResolverConfig = { + path: string; + substitutions?: Substitutions; +}; + +export class JsResolver { + constructor(private api: Api, private config: JsResolverConfig) {} + + compile(): string | IntrinsicFunction { + if (!fs.existsSync(this.config.path)) { + throw new this.api.plugin.serverless.classes.Error( + `The resolver handler file '${this.config.path}' does not exist`, + ); + } + + const requestTemplateContent = fs.readFileSync(this.config.path, 'utf8'); + return this.processTemplateSubstitutions(requestTemplateContent); + } + + processTemplateSubstitutions(template: string): string | IntrinsicFunction { + const substitutions = { + ...this.api.config.substitutions, + ...this.config.substitutions, + }; + const availableVariables = Object.keys(substitutions); + const templateVariables: string[] = []; + let searchResult; + const variableSyntax = RegExp(/#([\w\d-_]+)#/g); + while ((searchResult = variableSyntax.exec(template)) !== null) { + templateVariables.push(searchResult[1]); + } + + const replacements = availableVariables + .filter((value) => templateVariables.includes(value)) + .filter((value, index, array) => array.indexOf(value) === index) + .reduce( + (accum, value) => + Object.assign(accum, { [value]: substitutions[value] }), + {}, + ); + + // if there are substitutions for this template then add fn:sub + if (Object.keys(replacements).length > 0) { + return this.substituteGlobalTemplateVariables(template, replacements); + } + + return template; + } + + /** + * Creates Fn::Join object from given template where all given substitutions + * are wrapped in Fn::Sub objects. This enables template to have also + * characters that are not only alphanumeric, underscores, periods, and colons. + * + * @param {*} template + * @param {*} substitutions + */ + substituteGlobalTemplateVariables( + template: string, + substitutions: Substitutions, + ): IntrinsicFunction { + const variables = Object.keys(substitutions).join('|'); + const regex = new RegExp(`\\#(${variables})#`, 'g'); + const substituteTemplate = template.replace(regex, '|||$1|||'); + + const templateJoin = substituteTemplate + .split('|||') + .filter((part) => part !== ''); + const parts: (string | IntrinsicFunction)[] = []; + for (let i = 0; i < templateJoin.length; i += 1) { + if (templateJoin[i] in substitutions) { + const subs = { [templateJoin[i]]: substitutions[templateJoin[i]] }; + parts[i] = { 'Fn::Sub': [`\${${templateJoin[i]}}`, subs] }; + } else { + parts[i] = templateJoin[i]; + } + } + return { 'Fn::Join': ['', parts] }; + } +} diff --git a/src/resources/PipelineFunction.ts b/src/resources/PipelineFunction.ts index 89be01b8..0b2ab2a1 100644 --- a/src/resources/PipelineFunction.ts +++ b/src/resources/PipelineFunction.ts @@ -8,7 +8,7 @@ import { Api } from './Api'; import path from 'path'; import { MappingTemplate } from './MappingTemplate'; import { SyncConfig } from './SyncConfig'; -import fs from 'fs'; +import { JsResolver } from './JsResolver'; export class PipelineFunction { constructor(private api: Api, private config: PipelineFunctionConfig) {} @@ -68,19 +68,18 @@ export class PipelineFunction { }; } - resolveJsCode = (filePath: string): string => { + resolveJsCode = (filePath: string): string | IntrinsicFunction => { const codePath = path.join( this.api.plugin.serverless.config.servicePath, filePath, ); - if (!fs.existsSync(codePath)) { - throw new this.api.plugin.serverless.classes.Error( - `The resolver handler file '${codePath}' does not exist`, - ); - } + const template = new JsResolver(this.api, { + path: codePath, + substitutions: this.config.substitutions, + }); - return fs.readFileSync(codePath, 'utf8'); + return template.compile(); }; resolveMappingTemplate( diff --git a/src/resources/Resolver.ts b/src/resources/Resolver.ts index e8c77100..249777d1 100644 --- a/src/resources/Resolver.ts +++ b/src/resources/Resolver.ts @@ -8,7 +8,7 @@ import { Api } from './Api'; import path from 'path'; import { MappingTemplate } from './MappingTemplate'; import { SyncConfig } from './SyncConfig'; -import fs from 'fs'; +import { JsResolver } from './JsResolver'; // A decent default for pipeline JS resolvers const DEFAULT_JS_RESOLVERS = ` @@ -131,19 +131,18 @@ export class Resolver { }; } - resolveJsCode = (filePath: string): string => { + resolveJsCode = (filePath: string): string | IntrinsicFunction => { const codePath = path.join( this.api.plugin.serverless.config.servicePath, filePath, ); - if (!fs.existsSync(codePath)) { - throw new this.api.plugin.serverless.classes.Error( - `The resolver handler file '${codePath}' does not exist`, - ); - } + const template = new JsResolver(this.api, { + path: codePath, + substitutions: this.config.substitutions, + }); - return fs.readFileSync(codePath, 'utf8'); + return template.compile(); }; resolveMappingTemplate(