@friggframework/devtools 2.0.0-next.28 → 2.0.0-next.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frigg-cli/build-command/index.js +4 -2
- package/frigg-cli/deploy-command/index.js +5 -2
- package/frigg-cli/generate-iam-command.js +115 -0
- package/frigg-cli/index.js +11 -1
- package/infrastructure/AWS-DISCOVERY-TROUBLESHOOTING.md +245 -0
- package/infrastructure/AWS-IAM-CREDENTIAL-NEEDS.md +596 -0
- package/infrastructure/DEPLOYMENT-INSTRUCTIONS.md +268 -0
- package/infrastructure/GENERATE-IAM-DOCS.md +253 -0
- package/infrastructure/IAM-POLICY-TEMPLATES.md +176 -0
- package/infrastructure/README-TESTING.md +332 -0
- package/infrastructure/README.md +421 -0
- package/infrastructure/WEBSOCKET-CONFIGURATION.md +105 -0
- package/infrastructure/__tests__/fixtures/mock-aws-resources.js +391 -0
- package/infrastructure/__tests__/helpers/test-utils.js +277 -0
- package/infrastructure/aws-discovery.js +568 -0
- package/infrastructure/aws-discovery.test.js +373 -0
- package/infrastructure/build-time-discovery.js +206 -0
- package/infrastructure/build-time-discovery.test.js +375 -0
- package/infrastructure/create-frigg-infrastructure.js +2 -2
- package/infrastructure/frigg-deployment-iam-stack.yaml +379 -0
- package/infrastructure/iam-generator.js +687 -0
- package/infrastructure/iam-generator.test.js +169 -0
- package/infrastructure/iam-policy-basic.json +212 -0
- package/infrastructure/iam-policy-full.json +282 -0
- package/infrastructure/integration.test.js +383 -0
- package/infrastructure/run-discovery.js +110 -0
- package/infrastructure/serverless-template.js +514 -167
- package/infrastructure/serverless-template.test.js +541 -0
- package/management-ui/dist/assets/FriggLogo-B7Xx8ZW1.svg +1 -0
- package/management-ui/dist/assets/index-BA21WgFa.js +1221 -0
- package/management-ui/dist/assets/index-CbM64Oba.js +1221 -0
- package/management-ui/dist/assets/index-CkvseXTC.css +1 -0
- package/management-ui/dist/index.html +14 -0
- package/package.json +9 -5
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { BuildTimeDiscovery, runBuildTimeDiscovery } = require('./build-time-discovery');
|
|
4
|
+
const { AWSDiscovery } = require('./aws-discovery');
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
jest.mock('fs');
|
|
8
|
+
jest.mock('./aws-discovery');
|
|
9
|
+
|
|
10
|
+
describe('BuildTimeDiscovery', () => {
|
|
11
|
+
let buildTimeDiscovery;
|
|
12
|
+
let mockAWSDiscovery;
|
|
13
|
+
const originalEnv = process.env;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
buildTimeDiscovery = new BuildTimeDiscovery('us-east-1');
|
|
17
|
+
|
|
18
|
+
// Mock AWSDiscovery
|
|
19
|
+
mockAWSDiscovery = {
|
|
20
|
+
discoverResources: jest.fn(),
|
|
21
|
+
};
|
|
22
|
+
AWSDiscovery.mockImplementation(() => mockAWSDiscovery);
|
|
23
|
+
|
|
24
|
+
// Mock fs methods
|
|
25
|
+
fs.writeFileSync = jest.fn();
|
|
26
|
+
fs.readFileSync = jest.fn();
|
|
27
|
+
|
|
28
|
+
// Reset environment
|
|
29
|
+
process.env = { ...originalEnv };
|
|
30
|
+
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
process.env = originalEnv;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('constructor', () => {
|
|
39
|
+
it('should initialize with default region', () => {
|
|
40
|
+
const discovery = new BuildTimeDiscovery();
|
|
41
|
+
expect(discovery.region).toBe('us-east-1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should initialize with custom region', () => {
|
|
45
|
+
const discovery = new BuildTimeDiscovery('us-west-2');
|
|
46
|
+
expect(discovery.region).toBe('us-west-2');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should use AWS_REGION environment variable', () => {
|
|
50
|
+
process.env.AWS_REGION = 'eu-west-1';
|
|
51
|
+
const discovery = new BuildTimeDiscovery();
|
|
52
|
+
expect(discovery.region).toBe('eu-west-1');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('discoverAndCreateConfig', () => {
|
|
57
|
+
const mockResources = {
|
|
58
|
+
defaultVpcId: 'vpc-12345678',
|
|
59
|
+
defaultSecurityGroupId: 'sg-12345678',
|
|
60
|
+
privateSubnetId1: 'subnet-1',
|
|
61
|
+
privateSubnetId2: 'subnet-2',
|
|
62
|
+
privateRouteTableId: 'rtb-12345678',
|
|
63
|
+
defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
it('should discover resources and create config file', async () => {
|
|
67
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
68
|
+
|
|
69
|
+
const result = await buildTimeDiscovery.discoverAndCreateConfig('./test-config.json');
|
|
70
|
+
|
|
71
|
+
expect(mockAWSDiscovery.discoverResources).toHaveBeenCalled();
|
|
72
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
73
|
+
'./test-config.json',
|
|
74
|
+
expect.stringContaining('"awsDiscovery"')
|
|
75
|
+
);
|
|
76
|
+
expect(result.awsDiscovery).toEqual(mockResources);
|
|
77
|
+
expect(result.region).toBe('us-east-1');
|
|
78
|
+
expect(result.generatedAt).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should use default output path', async () => {
|
|
82
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
83
|
+
|
|
84
|
+
await buildTimeDiscovery.discoverAndCreateConfig();
|
|
85
|
+
|
|
86
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
87
|
+
'./aws-discovery-config.json',
|
|
88
|
+
expect.any(String)
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should throw error when discovery fails', async () => {
|
|
93
|
+
const error = new Error('Discovery failed');
|
|
94
|
+
mockAWSDiscovery.discoverResources.mockRejectedValue(error);
|
|
95
|
+
|
|
96
|
+
await expect(buildTimeDiscovery.discoverAndCreateConfig()).rejects.toThrow('Discovery failed');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('replaceTemplateVariables', () => {
|
|
101
|
+
const mockResources = {
|
|
102
|
+
defaultVpcId: 'vpc-12345678',
|
|
103
|
+
defaultSecurityGroupId: 'sg-12345678',
|
|
104
|
+
privateSubnetId1: 'subnet-1',
|
|
105
|
+
privateSubnetId2: 'subnet-2',
|
|
106
|
+
privateRouteTableId: 'rtb-12345678',
|
|
107
|
+
defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678'
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
it('should replace all AWS discovery placeholders', () => {
|
|
111
|
+
const templateContent = `
|
|
112
|
+
vpc:
|
|
113
|
+
id: \${self:custom.awsDiscovery.defaultVpcId}
|
|
114
|
+
securityGroups:
|
|
115
|
+
- \${self:custom.awsDiscovery.defaultSecurityGroupId}
|
|
116
|
+
subnets:
|
|
117
|
+
- \${self:custom.awsDiscovery.privateSubnetId1}
|
|
118
|
+
- \${self:custom.awsDiscovery.privateSubnetId2}
|
|
119
|
+
routeTable: \${self:custom.awsDiscovery.privateRouteTableId}
|
|
120
|
+
kms:
|
|
121
|
+
keyId: \${self:custom.awsDiscovery.defaultKmsKeyId}
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
const result = buildTimeDiscovery.replaceTemplateVariables(templateContent, mockResources);
|
|
125
|
+
|
|
126
|
+
expect(result).toContain('vpc-12345678');
|
|
127
|
+
expect(result).toContain('sg-12345678');
|
|
128
|
+
expect(result).toContain('subnet-1');
|
|
129
|
+
expect(result).toContain('subnet-2');
|
|
130
|
+
expect(result).toContain('rtb-12345678');
|
|
131
|
+
expect(result).toContain('arn:aws:kms:us-east-1:123456789012:key/12345678');
|
|
132
|
+
expect(result).not.toContain('${self:custom.awsDiscovery');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle content without placeholders', () => {
|
|
136
|
+
const templateContent = `
|
|
137
|
+
service: my-service
|
|
138
|
+
provider:
|
|
139
|
+
name: aws
|
|
140
|
+
runtime: nodejs18.x
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const result = buildTimeDiscovery.replaceTemplateVariables(templateContent, mockResources);
|
|
144
|
+
|
|
145
|
+
expect(result).toBe(templateContent);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle multiple occurrences of same placeholder', () => {
|
|
149
|
+
const templateContent = `
|
|
150
|
+
vpc1: \${self:custom.awsDiscovery.defaultVpcId}
|
|
151
|
+
vpc2: \${self:custom.awsDiscovery.defaultVpcId}
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const result = buildTimeDiscovery.replaceTemplateVariables(templateContent, mockResources);
|
|
155
|
+
|
|
156
|
+
expect(result).toBe(`
|
|
157
|
+
vpc1: vpc-12345678
|
|
158
|
+
vpc2: vpc-12345678
|
|
159
|
+
`);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('processServerlessConfig', () => {
|
|
164
|
+
const mockConfigContent = `
|
|
165
|
+
provider:
|
|
166
|
+
vpc: \${self:custom.awsDiscovery.defaultVpcId}
|
|
167
|
+
kms: \${self:custom.awsDiscovery.defaultKmsKeyId}
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
const mockResources = {
|
|
171
|
+
defaultVpcId: 'vpc-12345678',
|
|
172
|
+
defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678'
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
it('should process serverless config and update file', async () => {
|
|
176
|
+
fs.readFileSync.mockReturnValue(mockConfigContent);
|
|
177
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
178
|
+
|
|
179
|
+
const result = await buildTimeDiscovery.processServerlessConfig('./serverless.yml');
|
|
180
|
+
|
|
181
|
+
expect(fs.readFileSync).toHaveBeenCalledWith('./serverless.yml', 'utf8');
|
|
182
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
183
|
+
'./serverless.yml',
|
|
184
|
+
expect.stringContaining('vpc-12345678')
|
|
185
|
+
);
|
|
186
|
+
expect(result).toEqual(mockResources);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should write to different output file when specified', async () => {
|
|
190
|
+
fs.readFileSync.mockReturnValue(mockConfigContent);
|
|
191
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
192
|
+
|
|
193
|
+
await buildTimeDiscovery.processServerlessConfig('./serverless.yml', './serverless-processed.yml');
|
|
194
|
+
|
|
195
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
196
|
+
'./serverless-processed.yml',
|
|
197
|
+
expect.any(String)
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should throw error when file read fails', async () => {
|
|
202
|
+
fs.readFileSync.mockImplementation(() => {
|
|
203
|
+
throw new Error('File not found');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await expect(buildTimeDiscovery.processServerlessConfig('./nonexistent.yml')).rejects.toThrow('File not found');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('generateCustomSection', () => {
|
|
211
|
+
it('should generate custom section with discovered resources', () => {
|
|
212
|
+
const mockResources = {
|
|
213
|
+
defaultVpcId: 'vpc-12345678',
|
|
214
|
+
defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678'
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const result = buildTimeDiscovery.generateCustomSection(mockResources);
|
|
218
|
+
|
|
219
|
+
expect(result).toEqual({
|
|
220
|
+
awsDiscovery: mockResources
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('preBuildHook', () => {
|
|
226
|
+
const mockResources = {
|
|
227
|
+
defaultVpcId: 'vpc-12345678',
|
|
228
|
+
defaultSecurityGroupId: 'sg-12345678',
|
|
229
|
+
privateSubnetId1: 'subnet-1',
|
|
230
|
+
privateSubnetId2: 'subnet-2',
|
|
231
|
+
privateRouteTableId: 'rtb-12345678',
|
|
232
|
+
defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678'
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
it('should run discovery when VPC is enabled', async () => {
|
|
236
|
+
const appDefinition = {
|
|
237
|
+
vpc: { enable: true },
|
|
238
|
+
integrations: []
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
242
|
+
|
|
243
|
+
const result = await buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1');
|
|
244
|
+
|
|
245
|
+
expect(mockAWSDiscovery.discoverResources).toHaveBeenCalled();
|
|
246
|
+
expect(result).toEqual(mockResources);
|
|
247
|
+
expect(process.env.AWS_DISCOVERY_VPC_ID).toBe('vpc-12345678');
|
|
248
|
+
expect(process.env.AWS_DISCOVERY_KMS_KEY_ID).toBe('arn:aws:kms:us-east-1:123456789012:key/12345678');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should run discovery when KMS is enabled', async () => {
|
|
252
|
+
const appDefinition = {
|
|
253
|
+
encryption: { useDefaultKMSForFieldLevelEncryption: true },
|
|
254
|
+
integrations: []
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
258
|
+
|
|
259
|
+
const result = await buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1');
|
|
260
|
+
|
|
261
|
+
expect(mockAWSDiscovery.discoverResources).toHaveBeenCalled();
|
|
262
|
+
expect(result).toEqual(mockResources);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should run discovery when SSM is enabled', async () => {
|
|
266
|
+
const appDefinition = {
|
|
267
|
+
ssm: { enable: true },
|
|
268
|
+
integrations: []
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
272
|
+
|
|
273
|
+
const result = await buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1');
|
|
274
|
+
|
|
275
|
+
expect(mockAWSDiscovery.discoverResources).toHaveBeenCalled();
|
|
276
|
+
expect(result).toEqual(mockResources);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should skip discovery when no features are enabled', async () => {
|
|
280
|
+
const appDefinition = {
|
|
281
|
+
integrations: []
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const result = await buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1');
|
|
285
|
+
|
|
286
|
+
expect(mockAWSDiscovery.discoverResources).not.toHaveBeenCalled();
|
|
287
|
+
expect(result).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should throw error when discovery fails', async () => {
|
|
291
|
+
const appDefinition = {
|
|
292
|
+
vpc: { enable: true },
|
|
293
|
+
integrations: []
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const error = new Error('Discovery failed');
|
|
297
|
+
mockAWSDiscovery.discoverResources.mockRejectedValue(error);
|
|
298
|
+
|
|
299
|
+
await expect(buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1')).rejects.toThrow('Discovery failed');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should set all environment variables', async () => {
|
|
303
|
+
const appDefinition = {
|
|
304
|
+
vpc: { enable: true },
|
|
305
|
+
integrations: []
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
309
|
+
|
|
310
|
+
await buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1');
|
|
311
|
+
|
|
312
|
+
expect(process.env.AWS_DISCOVERY_VPC_ID).toBe('vpc-12345678');
|
|
313
|
+
expect(process.env.AWS_DISCOVERY_SECURITY_GROUP_ID).toBe('sg-12345678');
|
|
314
|
+
expect(process.env.AWS_DISCOVERY_SUBNET_ID_1).toBe('subnet-1');
|
|
315
|
+
expect(process.env.AWS_DISCOVERY_SUBNET_ID_2).toBe('subnet-2');
|
|
316
|
+
expect(process.env.AWS_DISCOVERY_ROUTE_TABLE_ID).toBe('rtb-12345678');
|
|
317
|
+
expect(process.env.AWS_DISCOVERY_KMS_KEY_ID).toBe('arn:aws:kms:us-east-1:123456789012:key/12345678');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('runBuildTimeDiscovery', () => {
|
|
322
|
+
it('should run discovery with default options', async () => {
|
|
323
|
+
const mockResources = { defaultVpcId: 'vpc-12345678' };
|
|
324
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
325
|
+
|
|
326
|
+
const result = await runBuildTimeDiscovery();
|
|
327
|
+
|
|
328
|
+
expect(result.awsDiscovery).toEqual(mockResources);
|
|
329
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
330
|
+
'./aws-discovery-config.json',
|
|
331
|
+
expect.any(String)
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should process config file when configPath provided', async () => {
|
|
336
|
+
const mockConfigContent = 'provider: aws';
|
|
337
|
+
const mockResources = { defaultVpcId: 'vpc-12345678' };
|
|
338
|
+
|
|
339
|
+
fs.readFileSync.mockReturnValue(mockConfigContent);
|
|
340
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
341
|
+
|
|
342
|
+
const result = await runBuildTimeDiscovery({
|
|
343
|
+
configPath: './serverless.yml'
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(result).toEqual(mockResources);
|
|
347
|
+
expect(fs.readFileSync).toHaveBeenCalledWith('./serverless.yml', 'utf8');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should use custom region and output path', async () => {
|
|
351
|
+
const mockResources = { defaultVpcId: 'vpc-12345678' };
|
|
352
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
353
|
+
|
|
354
|
+
await runBuildTimeDiscovery({
|
|
355
|
+
region: 'eu-west-1',
|
|
356
|
+
outputPath: './custom-config.json'
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
|
360
|
+
'./custom-config.json',
|
|
361
|
+
expect.any(String)
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should use AWS_REGION environment variable when region not specified', async () => {
|
|
366
|
+
process.env.AWS_REGION = 'ap-southeast-1';
|
|
367
|
+
const mockResources = { defaultVpcId: 'vpc-12345678' };
|
|
368
|
+
mockAWSDiscovery.discoverResources.mockResolvedValue(mockResources);
|
|
369
|
+
|
|
370
|
+
const result = await runBuildTimeDiscovery();
|
|
371
|
+
|
|
372
|
+
expect(result.region).toBe('ap-southeast-1');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -4,7 +4,7 @@ const { composeServerlessDefinition } = require('./serverless-template');
|
|
|
4
4
|
|
|
5
5
|
const { findNearestBackendPackageJson } = require('@friggframework/core');
|
|
6
6
|
|
|
7
|
-
function createFriggInfrastructure() {
|
|
7
|
+
async function createFriggInfrastructure() {
|
|
8
8
|
const backendPath = findNearestBackendPackageJson();
|
|
9
9
|
if (!backendPath) {
|
|
10
10
|
throw new Error('Could not find backend package.json');
|
|
@@ -23,7 +23,7 @@ function createFriggInfrastructure() {
|
|
|
23
23
|
// __dirname,
|
|
24
24
|
// './serverless-template.js'
|
|
25
25
|
// ));
|
|
26
|
-
const definition = composeServerlessDefinition(
|
|
26
|
+
const definition = await composeServerlessDefinition(
|
|
27
27
|
appDefinition,
|
|
28
28
|
backend.IntegrationFactory
|
|
29
29
|
);
|