Skip to content

Commit ded4339

Browse files
ebisbebboure
authored andcommitted
feat(resolvers): Implement substitutions for js resolvers (#545)
1 parent 11a99c8 commit ded4339

File tree

5 files changed

+241
-18
lines changed

5 files changed

+241
-18
lines changed

src/__tests__/js-resolvers.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Api } from '../resources/Api';
2+
import { JsResolver } from '../resources/JsResolver';
3+
import * as given from './given';
4+
import fs from 'fs';
5+
6+
const plugin = given.plugin();
7+
8+
describe('Mapping Templates', () => {
9+
let mock: jest.SpyInstance;
10+
let mockEists: jest.SpyInstance;
11+
12+
beforeEach(() => {
13+
mock = jest
14+
.spyOn(fs, 'readFileSync')
15+
.mockImplementation(
16+
(path) => `Content of ${`${path}`.replace(/\\/g, '/')}`,
17+
);
18+
mockEists = jest.spyOn(fs, 'existsSync').mockReturnValue(true);
19+
});
20+
21+
afterEach(() => {
22+
mock.mockRestore();
23+
mockEists.mockRestore();
24+
});
25+
26+
it('should substitute variables', () => {
27+
const api = new Api(given.appSyncConfig(), plugin);
28+
const mapping = new JsResolver(api, {
29+
path: 'foo.vtl',
30+
substitutions: {
31+
foo: 'bar',
32+
var: { Ref: 'MyReference' },
33+
},
34+
});
35+
const template = `const foo = '#foo#';
36+
const var = '#var#';
37+
const unknonw = '#unknown#'`;
38+
expect(mapping.processTemplateSubstitutions(template))
39+
.toMatchInlineSnapshot(`
40+
Object {
41+
"Fn::Join": Array [
42+
"",
43+
Array [
44+
"const foo = '",
45+
Object {
46+
"Fn::Sub": Array [
47+
"\${foo}",
48+
Object {
49+
"foo": "bar",
50+
},
51+
],
52+
},
53+
"';
54+
const var = '",
55+
Object {
56+
"Fn::Sub": Array [
57+
"\${var}",
58+
Object {
59+
"var": Object {
60+
"Ref": "MyReference",
61+
},
62+
},
63+
],
64+
},
65+
"';
66+
const unknonw = '#unknown#'",
67+
],
68+
],
69+
}
70+
`);
71+
});
72+
73+
it('should substitute variables and use defaults', () => {
74+
const api = new Api(
75+
given.appSyncConfig({
76+
substitutions: {
77+
foo: 'bar',
78+
var: 'bizz',
79+
},
80+
}),
81+
plugin,
82+
);
83+
const mapping = new JsResolver(api, {
84+
path: 'foo.vtl',
85+
substitutions: {
86+
foo: 'fuzz',
87+
},
88+
});
89+
const template = `const foo = '#foo#';
90+
const var = '#var#';`;
91+
expect(mapping.processTemplateSubstitutions(template))
92+
.toMatchInlineSnapshot(`
93+
Object {
94+
"Fn::Join": Array [
95+
"",
96+
Array [
97+
"const foo = '",
98+
Object {
99+
"Fn::Sub": Array [
100+
"\${foo}",
101+
Object {
102+
"foo": "fuzz",
103+
},
104+
],
105+
},
106+
"';
107+
const var = '",
108+
Object {
109+
"Fn::Sub": Array [
110+
"\${var}",
111+
Object {
112+
"var": "bizz",
113+
},
114+
],
115+
},
116+
"';",
117+
],
118+
],
119+
}
120+
`);
121+
});
122+
123+
it('should fail if template is missing', () => {
124+
mockEists = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
125+
const api = new Api(given.appSyncConfig(), plugin);
126+
const mapping = new JsResolver(api, {
127+
path: 'foo.vtl',
128+
substitutions: {
129+
foo: 'bar',
130+
var: { Ref: 'MyReference' },
131+
},
132+
});
133+
134+
expect(function () {
135+
mapping.compile();
136+
}).toThrowErrorMatchingInlineSnapshot(
137+
`"The resolver handler file 'foo.vtl' does not exist"`,
138+
);
139+
});
140+
});

src/__tests__/mapping-templates.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('Mapping Templates', () => {
2323
mockEists.mockRestore();
2424
});
2525

26-
it('should substritute variables', () => {
26+
it('should substitute variables', () => {
2727
const api = new Api(given.appSyncConfig(), plugin);
2828
const mapping = new MappingTemplate(api, {
2929
path: 'foo.vtl',
@@ -67,7 +67,7 @@ describe('Mapping Templates', () => {
6767
`);
6868
});
6969

70-
it('should substritute variables and use defaults', () => {
70+
it('should substitute variables and use defaults', () => {
7171
const api = new Api(
7272
given.appSyncConfig({
7373
substitutions: {

src/resources/JsResolver.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { IntrinsicFunction } from '../types/cloudFormation';
2+
import fs from 'fs';
3+
import { Substitutions } from '../types/plugin';
4+
import { Api } from './Api';
5+
6+
type JsResolverConfig = {
7+
path: string;
8+
substitutions?: Substitutions;
9+
};
10+
11+
export class JsResolver {
12+
constructor(private api: Api, private config: JsResolverConfig) {}
13+
14+
compile(): string | IntrinsicFunction {
15+
if (!fs.existsSync(this.config.path)) {
16+
throw new this.api.plugin.serverless.classes.Error(
17+
`The resolver handler file '${this.config.path}' does not exist`,
18+
);
19+
}
20+
21+
const requestTemplateContent = fs.readFileSync(this.config.path, 'utf8');
22+
return this.processTemplateSubstitutions(requestTemplateContent);
23+
}
24+
25+
processTemplateSubstitutions(template: string): string | IntrinsicFunction {
26+
const substitutions = {
27+
...this.api.config.substitutions,
28+
...this.config.substitutions,
29+
};
30+
const availableVariables = Object.keys(substitutions);
31+
const templateVariables: string[] = [];
32+
let searchResult;
33+
const variableSyntax = RegExp(/#([\w\d-_]+)#/g);
34+
while ((searchResult = variableSyntax.exec(template)) !== null) {
35+
templateVariables.push(searchResult[1]);
36+
}
37+
38+
const replacements = availableVariables
39+
.filter((value) => templateVariables.includes(value))
40+
.filter((value, index, array) => array.indexOf(value) === index)
41+
.reduce(
42+
(accum, value) =>
43+
Object.assign(accum, { [value]: substitutions[value] }),
44+
{},
45+
);
46+
47+
// if there are substitutions for this template then add fn:sub
48+
if (Object.keys(replacements).length > 0) {
49+
return this.substituteGlobalTemplateVariables(template, replacements);
50+
}
51+
52+
return template;
53+
}
54+
55+
/**
56+
* Creates Fn::Join object from given template where all given substitutions
57+
* are wrapped in Fn::Sub objects. This enables template to have also
58+
* characters that are not only alphanumeric, underscores, periods, and colons.
59+
*
60+
* @param {*} template
61+
* @param {*} substitutions
62+
*/
63+
substituteGlobalTemplateVariables(
64+
template: string,
65+
substitutions: Substitutions,
66+
): IntrinsicFunction {
67+
const variables = Object.keys(substitutions).join('|');
68+
const regex = new RegExp(`\\#(${variables})#`, 'g');
69+
const substituteTemplate = template.replace(regex, '|||$1|||');
70+
71+
const templateJoin = substituteTemplate
72+
.split('|||')
73+
.filter((part) => part !== '');
74+
const parts: (string | IntrinsicFunction)[] = [];
75+
for (let i = 0; i < templateJoin.length; i += 1) {
76+
if (templateJoin[i] in substitutions) {
77+
const subs = { [templateJoin[i]]: substitutions[templateJoin[i]] };
78+
parts[i] = { 'Fn::Sub': [`\${${templateJoin[i]}}`, subs] };
79+
} else {
80+
parts[i] = templateJoin[i];
81+
}
82+
}
83+
return { 'Fn::Join': ['', parts] };
84+
}
85+
}

src/resources/PipelineFunction.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Api } from './Api';
88
import path from 'path';
99
import { MappingTemplate } from './MappingTemplate';
1010
import { SyncConfig } from './SyncConfig';
11-
import fs from 'fs';
11+
import { JsResolver } from './JsResolver';
1212

1313
export class PipelineFunction {
1414
constructor(private api: Api, private config: PipelineFunctionConfig) {}
@@ -68,19 +68,18 @@ export class PipelineFunction {
6868
};
6969
}
7070

71-
resolveJsCode = (filePath: string): string => {
71+
resolveJsCode = (filePath: string): string | IntrinsicFunction => {
7272
const codePath = path.join(
7373
this.api.plugin.serverless.config.servicePath,
7474
filePath,
7575
);
7676

77-
if (!fs.existsSync(codePath)) {
78-
throw new this.api.plugin.serverless.classes.Error(
79-
`The resolver handler file '${codePath}' does not exist`,
80-
);
81-
}
77+
const template = new JsResolver(this.api, {
78+
path: codePath,
79+
substitutions: this.config.substitutions,
80+
});
8281

83-
return fs.readFileSync(codePath, 'utf8');
82+
return template.compile();
8483
};
8584

8685
resolveMappingTemplate(

src/resources/Resolver.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Api } from './Api';
88
import path from 'path';
99
import { MappingTemplate } from './MappingTemplate';
1010
import { SyncConfig } from './SyncConfig';
11-
import fs from 'fs';
11+
import { JsResolver } from './JsResolver';
1212

1313
// A decent default for pipeline JS resolvers
1414
const DEFAULT_JS_RESOLVERS = `
@@ -131,19 +131,18 @@ export class Resolver {
131131
};
132132
}
133133

134-
resolveJsCode = (filePath: string): string => {
134+
resolveJsCode = (filePath: string): string | IntrinsicFunction => {
135135
const codePath = path.join(
136136
this.api.plugin.serverless.config.servicePath,
137137
filePath,
138138
);
139139

140-
if (!fs.existsSync(codePath)) {
141-
throw new this.api.plugin.serverless.classes.Error(
142-
`The resolver handler file '${codePath}' does not exist`,
143-
);
144-
}
140+
const template = new JsResolver(this.api, {
141+
path: codePath,
142+
substitutions: this.config.substitutions,
143+
});
145144

146-
return fs.readFileSync(codePath, 'utf8');
145+
return template.compile();
147146
};
148147

149148
resolveMappingTemplate(

0 commit comments

Comments
 (0)