Skip to content

Commit

Permalink
feat(resolvers): Implement substitutions for js resolvers (#545)
Browse files Browse the repository at this point in the history
  • Loading branch information
ebisbe authored and bboure committed Jan 13, 2023
1 parent 11a99c8 commit ded4339
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 18 deletions.
140 changes: 140 additions & 0 deletions src/__tests__/js-resolvers.test.ts
Original file line number Diff line number Diff line change
@@ -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"`,
);
});
});
4 changes: 2 additions & 2 deletions src/__tests__/mapping-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down
85 changes: 85 additions & 0 deletions src/resources/JsResolver.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
}
15 changes: 7 additions & 8 deletions src/resources/PipelineFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 7 additions & 8 deletions src/resources/Resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit ded4339

Please sign in to comment.