@transai/connector-runner-file 0.1.0
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/.eslintrc.json +18 -0
- package/CHANGELOG.md +19 -0
- package/README.md +11 -0
- package/jest.config.ts +10 -0
- package/package.json +16 -0
- package/project.json +33 -0
- package/src/index.ts +2 -0
- package/src/lib/actions-handler.spec.ts +121 -0
- package/src/lib/actions-handler.ts +82 -0
- package/src/lib/connector-runner-file.spec.ts +73 -0
- package/src/lib/connector-runner-file.ts +50 -0
- package/src/lib/processor.spec.ts +119 -0
- package/src/lib/processor.ts +186 -0
- package/src/lib/types.ts +51 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +14 -0
package/.eslintrc.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["../../.eslintrc.json"],
|
|
3
|
+
"ignorePatterns": ["!**/*"],
|
|
4
|
+
"overrides": [
|
|
5
|
+
{
|
|
6
|
+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
|
7
|
+
"rules": {}
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"files": ["*.ts", "*.tsx"],
|
|
11
|
+
"rules": {}
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"files": ["*.js", "*.jsx"],
|
|
15
|
+
"rules": {}
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## 0.1.0 (2025-11-19)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- fix connector packages publish ([517d2fd2](https://github.com/xip-online-applications/xod-core/commit/517d2fd2))
|
|
6
|
+
- **XODO-1133:** added file connector that uses the SDK ([#875](https://github.com/xip-online-applications/xod-core/pull/875))
|
|
7
|
+
- **XODO-727:** connector orchestration config through API call ([#797](https://github.com/xip-online-applications/xod-core/pull/797))
|
|
8
|
+
- **XODO-852:** made connectors publish to npm ([89cd40eb](https://github.com/xip-online-applications/xod-core/commit/89cd40eb))
|
|
9
|
+
- **XODO-852:** made connectors buildable ([6ca5f111](https://github.com/xip-online-applications/xod-core/commit/6ca5f111))
|
|
10
|
+
|
|
11
|
+
### 🧱 Updated Dependencies
|
|
12
|
+
|
|
13
|
+
- Updated @transai/connector-runtime-sdk to 0.1.0
|
|
14
|
+
|
|
15
|
+
### ❤️ Thank You
|
|
16
|
+
|
|
17
|
+
- Copilot @Copilot
|
|
18
|
+
- Rene Heijdens @H31nz3l
|
|
19
|
+
- Youri Lefers @yourilefers
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# connector-runner-file
|
|
2
|
+
|
|
3
|
+
This library was generated with [Nx](https://nx.dev).
|
|
4
|
+
|
|
5
|
+
## Building
|
|
6
|
+
|
|
7
|
+
Run `nx build connector-runner-file` to build the library.
|
|
8
|
+
|
|
9
|
+
## Running unit tests
|
|
10
|
+
|
|
11
|
+
Run `nx test connector-runner-file` to execute the unit tests via [Jest](https://jestjs.io).
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
displayName: 'connector-runner-file',
|
|
3
|
+
preset: '../../jest.preset.js',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
|
7
|
+
},
|
|
8
|
+
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
|
+
coverageDirectory: '../../coverage/libs/connector-runner-file',
|
|
10
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@transai/connector-runner-file",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"license": "LGPL-3.0-or-later",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "transAI",
|
|
10
|
+
"email": "samen@transai.com",
|
|
11
|
+
"url": "https://transai.com"
|
|
12
|
+
},
|
|
13
|
+
"type": "commonjs",
|
|
14
|
+
"main": "./index.js",
|
|
15
|
+
"typings": "./index.d.ts"
|
|
16
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "connector-runner-file",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "libs/connector-runner-file/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [
|
|
7
|
+
"connector:runner",
|
|
8
|
+
"connector:runner-sdk",
|
|
9
|
+
"connector:file",
|
|
10
|
+
"connector:source",
|
|
11
|
+
"connector:sink",
|
|
12
|
+
"provider:@transai"
|
|
13
|
+
],
|
|
14
|
+
"targets": {
|
|
15
|
+
"build": {
|
|
16
|
+
"executor": "@transai/tools:connector",
|
|
17
|
+
"outputs": ["{options.outputPath}"],
|
|
18
|
+
"options": {
|
|
19
|
+
"external": ["cpu-features", "ssh2"]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"lint": {
|
|
23
|
+
"executor": "@nx/eslint:lint"
|
|
24
|
+
},
|
|
25
|
+
"test": {
|
|
26
|
+
"executor": "@nx/jest:jest",
|
|
27
|
+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
|
28
|
+
"options": {
|
|
29
|
+
"jestConfig": "libs/connector-runner-file/jest.config.ts"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectorSDKInterface,
|
|
3
|
+
FilesSDKInterface,
|
|
4
|
+
} from '@transai/connector-runtime-sdk';
|
|
5
|
+
import { ActionInterface, XodJobType } from '@xip-online-data/types';
|
|
6
|
+
|
|
7
|
+
import { ActionsHandler } from './actions-handler';
|
|
8
|
+
import { FileConfig } from './types';
|
|
9
|
+
|
|
10
|
+
describe('FileActionsHandler', () => {
|
|
11
|
+
let handler: ActionsHandler;
|
|
12
|
+
|
|
13
|
+
const sdkMock = {
|
|
14
|
+
logger: {
|
|
15
|
+
warn: jest.fn(),
|
|
16
|
+
info: jest.fn(),
|
|
17
|
+
debug: jest.fn(),
|
|
18
|
+
error: jest.fn(),
|
|
19
|
+
verbose: jest.fn(),
|
|
20
|
+
},
|
|
21
|
+
receiver: {
|
|
22
|
+
responses: {
|
|
23
|
+
created: jest.fn().mockReturnValue(
|
|
24
|
+
jest.fn().mockReturnValue({
|
|
25
|
+
statusCode: 201,
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
badRequest: jest.fn().mockReturnValue(
|
|
29
|
+
jest.fn().mockReturnValue({
|
|
30
|
+
statusCode: 400,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
internalServerError: jest.fn().mockReturnValue(
|
|
34
|
+
jest.fn().mockReturnValue({
|
|
35
|
+
statusCode: 500,
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
emitEventType: jest.fn().mockReturnValue(
|
|
40
|
+
jest.fn().mockReturnValue({
|
|
41
|
+
statusCode: 201,
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
} as unknown as ConnectorSDKInterface<FileConfig>;
|
|
46
|
+
let fileHandlerMock: FilesSDKInterface;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
fileHandlerMock = {
|
|
50
|
+
write: jest.fn(),
|
|
51
|
+
} as unknown as FilesSDKInterface;
|
|
52
|
+
|
|
53
|
+
handler = new ActionsHandler(sdkMock, fileHandlerMock);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should be defined', () => {
|
|
61
|
+
expect(handler).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('JOB actions', () => {
|
|
65
|
+
const action = {
|
|
66
|
+
identifier: 'action-1',
|
|
67
|
+
version: '2.0',
|
|
68
|
+
config: {
|
|
69
|
+
parsedTemplates: {
|
|
70
|
+
filename: jest.fn().mockReturnValue('test-file.txt'),
|
|
71
|
+
contents: jest.fn().mockReturnValue('file contents here'),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} as unknown as ActionInterface;
|
|
75
|
+
|
|
76
|
+
const jobMessage = {
|
|
77
|
+
type: 'JOB',
|
|
78
|
+
actionIdentifier: 'action-1',
|
|
79
|
+
actionVersion: 'latest',
|
|
80
|
+
payload: { some: 'data' },
|
|
81
|
+
} as unknown as XodJobType;
|
|
82
|
+
|
|
83
|
+
it('should write file on action', async () => {
|
|
84
|
+
const response = await handler.callbackFunctionChain(jobMessage, action);
|
|
85
|
+
|
|
86
|
+
expect(fileHandlerMock.write).toHaveBeenCalledWith(
|
|
87
|
+
'test-file.txt',
|
|
88
|
+
'file contents here',
|
|
89
|
+
);
|
|
90
|
+
expect(response.statusCode).toBe(201);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle errors during file write', async () => {
|
|
94
|
+
fileHandlerMock.write = jest
|
|
95
|
+
.fn()
|
|
96
|
+
.mockRejectedValue(new Error('Write error'));
|
|
97
|
+
|
|
98
|
+
const response = await handler.callbackFunctionChain(jobMessage, action);
|
|
99
|
+
|
|
100
|
+
expect(response.statusCode).toBe(500);
|
|
101
|
+
expect(
|
|
102
|
+
sdkMock.receiver.responses.internalServerError,
|
|
103
|
+
).toHaveBeenCalledWith('Write error');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should handle test run without writing file', async () => {
|
|
107
|
+
const testRunMessage = {
|
|
108
|
+
...jobMessage,
|
|
109
|
+
testRun: true,
|
|
110
|
+
} as XodJobType;
|
|
111
|
+
|
|
112
|
+
const response = await handler.callbackFunctionChain(
|
|
113
|
+
testRunMessage,
|
|
114
|
+
action,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(fileHandlerMock.write).not.toHaveBeenCalled();
|
|
118
|
+
expect(response.statusCode).toBe(201);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CompileDelegate,
|
|
3
|
+
ConnectorSDKInterface,
|
|
4
|
+
FilesSDKInterface,
|
|
5
|
+
} from '@transai/connector-runtime-sdk';
|
|
6
|
+
import {
|
|
7
|
+
ActionInterface,
|
|
8
|
+
KafkaCallbackResponse,
|
|
9
|
+
XodJobType,
|
|
10
|
+
} from '@xip-online-data/types';
|
|
11
|
+
|
|
12
|
+
export class ActionsHandler {
|
|
13
|
+
readonly #connectorSDK: ConnectorSDKInterface;
|
|
14
|
+
|
|
15
|
+
readonly #fileHandler: FilesSDKInterface;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
connectorSDK: ConnectorSDKInterface,
|
|
19
|
+
fileHandler: FilesSDKInterface,
|
|
20
|
+
) {
|
|
21
|
+
this.#connectorSDK = connectorSDK;
|
|
22
|
+
this.#fileHandler = fileHandler;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get callbackFunctionChain(): (
|
|
26
|
+
message: XodJobType,
|
|
27
|
+
action: ActionInterface,
|
|
28
|
+
) => Promise<KafkaCallbackResponse> {
|
|
29
|
+
return this.#jobCallbackFunction(
|
|
30
|
+
this.#connectorSDK.receiver.emitEventType(
|
|
31
|
+
this.#connectorSDK.receiver.responses.created(),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#jobCallbackFunction(
|
|
37
|
+
callbackFunction: (message: XodJobType) => Promise<KafkaCallbackResponse>,
|
|
38
|
+
) {
|
|
39
|
+
return async (
|
|
40
|
+
message: XodJobType,
|
|
41
|
+
action: ActionInterface,
|
|
42
|
+
): Promise<KafkaCallbackResponse> => {
|
|
43
|
+
this.#connectorSDK.logger.debug(
|
|
44
|
+
`Apply templates on payload: ${JSON.stringify(message.payload)}, action: ${JSON.stringify(action)}`,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const handleBars = action.config['parsedTemplates'] as {
|
|
48
|
+
filename: CompileDelegate;
|
|
49
|
+
contents: CompileDelegate;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const parsedFilename = handleBars
|
|
54
|
+
.filename({
|
|
55
|
+
inputs: message.payload,
|
|
56
|
+
})
|
|
57
|
+
.trim();
|
|
58
|
+
|
|
59
|
+
const parsedContent = handleBars
|
|
60
|
+
.contents({
|
|
61
|
+
inputs: message.payload,
|
|
62
|
+
})
|
|
63
|
+
.trim();
|
|
64
|
+
|
|
65
|
+
if (message.testRun) {
|
|
66
|
+
this.#connectorSDK.logger.debug(
|
|
67
|
+
`Test run for ${message.eventId} with parsedContent ${parsedContent}, parsedFilename ${parsedFilename}`,
|
|
68
|
+
);
|
|
69
|
+
return callbackFunction(message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await this.#fileHandler.write(parsedFilename, parsedContent);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return this.#connectorSDK.receiver.responses.internalServerError(
|
|
75
|
+
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
76
|
+
)(message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return callbackFunction(message);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectorSDKInterface,
|
|
3
|
+
FilesSDKInterface,
|
|
4
|
+
} from '@transai/connector-runtime-sdk';
|
|
5
|
+
import { ConnectorInterface } from '@xip-online-data/types';
|
|
6
|
+
|
|
7
|
+
import { ConnectorRunnerFile } from './connector-runner-file';
|
|
8
|
+
import { Processor } from './processor';
|
|
9
|
+
import { FileConfig } from './types';
|
|
10
|
+
|
|
11
|
+
jest.mock('./processor');
|
|
12
|
+
|
|
13
|
+
describe('ConnectorRunnerFile', () => {
|
|
14
|
+
let runner: ConnectorRunnerFile;
|
|
15
|
+
|
|
16
|
+
let sdkMock: ConnectorSDKInterface<FileConfig>;
|
|
17
|
+
const processorMock = {} as unknown as Processor;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
(Processor as unknown as jest.Mock).mockImplementation(() => processorMock);
|
|
21
|
+
|
|
22
|
+
sdkMock = {
|
|
23
|
+
logger: {
|
|
24
|
+
warn: jest.fn(),
|
|
25
|
+
info: jest.fn(),
|
|
26
|
+
debug: jest.fn(),
|
|
27
|
+
error: jest.fn(),
|
|
28
|
+
},
|
|
29
|
+
config: {
|
|
30
|
+
dsn: 'dummy://path/to/files',
|
|
31
|
+
fileSelectors: [{ selector: 'some-file-.+' }],
|
|
32
|
+
},
|
|
33
|
+
receiver: {
|
|
34
|
+
emitEventType: jest.fn(),
|
|
35
|
+
responses: {
|
|
36
|
+
created: jest.fn(),
|
|
37
|
+
},
|
|
38
|
+
registerCallback: jest.fn(),
|
|
39
|
+
},
|
|
40
|
+
processing: {
|
|
41
|
+
registerInterval: jest.fn(),
|
|
42
|
+
},
|
|
43
|
+
files: jest.fn().mockReturnValue({
|
|
44
|
+
pathAsDsn: (path: string) => `dummy:${path}`,
|
|
45
|
+
}),
|
|
46
|
+
} as unknown as ConnectorSDKInterface<FileConfig>;
|
|
47
|
+
|
|
48
|
+
runner = new ConnectorRunnerFile({} as ConnectorInterface, sdkMock);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should be defined', () => {
|
|
52
|
+
expect(runner).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should initialize file selector processes', async () => {
|
|
56
|
+
(Processor as unknown as jest.Mock).mockImplementation(
|
|
57
|
+
(sdk, fileSelector, fileHandler: FilesSDKInterface) => {
|
|
58
|
+
expect(sdk).toBe(sdkMock);
|
|
59
|
+
expect(fileSelector).toEqual({ selector: 'some-file-.+' });
|
|
60
|
+
expect(fileHandler.pathAsDsn('/')).toEqual('dummy:/');
|
|
61
|
+
|
|
62
|
+
return processorMock;
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await runner.init();
|
|
67
|
+
|
|
68
|
+
expect(sdkMock.processing.registerInterval).toHaveBeenCalledWith(
|
|
69
|
+
60,
|
|
70
|
+
processorMock,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectorRuntimeSDK,
|
|
3
|
+
ConnectorSDKInterface,
|
|
4
|
+
FilesSDKInterface,
|
|
5
|
+
} from '@transai/connector-runtime-sdk';
|
|
6
|
+
import { ConnectorInterface } from '@xip-online-data/types';
|
|
7
|
+
|
|
8
|
+
import { ActionsHandler } from './actions-handler';
|
|
9
|
+
import { Processor } from './processor';
|
|
10
|
+
import { FileConfig } from './types';
|
|
11
|
+
|
|
12
|
+
export class ConnectorRunnerFile extends ConnectorRuntimeSDK<FileConfig> {
|
|
13
|
+
readonly #fileHandler: FilesSDKInterface;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
connector: ConnectorInterface,
|
|
17
|
+
connectorSDK: ConnectorSDKInterface,
|
|
18
|
+
) {
|
|
19
|
+
super(connector, connectorSDK);
|
|
20
|
+
|
|
21
|
+
const { config } = this.connectorSDK;
|
|
22
|
+
this.#fileHandler = this.connectorSDK.files(config.dsn);
|
|
23
|
+
|
|
24
|
+
const actionsHandler = new ActionsHandler(
|
|
25
|
+
this.connectorSDK,
|
|
26
|
+
this.#fileHandler,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
this.callbackFunction = actionsHandler.callbackFunctionChain;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override init = async (): Promise<void> => {
|
|
33
|
+
const { config } = this.connectorSDK;
|
|
34
|
+
|
|
35
|
+
await Promise.all(
|
|
36
|
+
(config.fileSelectors ?? []).map(async (fileSelector) => {
|
|
37
|
+
const processor = new Processor(
|
|
38
|
+
this.connectorSDK,
|
|
39
|
+
fileSelector,
|
|
40
|
+
this.#fileHandler,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
await this.connectorSDK.processing.registerInterval(
|
|
44
|
+
fileSelector.intervalSeconds ?? Processor.DEFAULT_INTERVAL_SECONDS,
|
|
45
|
+
processor,
|
|
46
|
+
);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectorSDKInterface,
|
|
3
|
+
FilesSDKInterface,
|
|
4
|
+
} from '@transai/connector-runtime-sdk';
|
|
5
|
+
// eslint-disable-next-line @nx/enforce-module-boundaries
|
|
6
|
+
import { FileHandler } from '@xip-online-data/file-handler';
|
|
7
|
+
|
|
8
|
+
import { Processor } from './processor';
|
|
9
|
+
import { FileConfig } from './types';
|
|
10
|
+
|
|
11
|
+
jest.mock('@xip-online-data/file-handler');
|
|
12
|
+
|
|
13
|
+
describe('FileProcessor', () => {
|
|
14
|
+
let processor: Processor;
|
|
15
|
+
|
|
16
|
+
const sourceDsn = 'dummy://source/path';
|
|
17
|
+
const destinationDsn = 'dummy://destination/path';
|
|
18
|
+
|
|
19
|
+
const readFile = {
|
|
20
|
+
get: jest.fn().mockReturnValue(Buffer.from('some-string')),
|
|
21
|
+
close: jest.fn().mockReturnValue(true),
|
|
22
|
+
};
|
|
23
|
+
const parsedFile = { foo: 'bar' };
|
|
24
|
+
|
|
25
|
+
const fileHandlerMock = {
|
|
26
|
+
list: jest.fn().mockResolvedValue([
|
|
27
|
+
{ type: 'FILE', name: 'test-file-001.csv' },
|
|
28
|
+
{ type: 'FILE', name: 'other-file.txt' },
|
|
29
|
+
{ type: 'DIRECTORY', name: 'some-dir' },
|
|
30
|
+
]),
|
|
31
|
+
read: jest.fn().mockResolvedValue(readFile),
|
|
32
|
+
delete: jest.fn(),
|
|
33
|
+
} as unknown as FilesSDKInterface;
|
|
34
|
+
const destinationFileHandlerMock = {
|
|
35
|
+
write: jest.fn(),
|
|
36
|
+
pathAsDsn: jest.fn(),
|
|
37
|
+
} as unknown as FilesSDKInterface;
|
|
38
|
+
const fileReaderMock = {
|
|
39
|
+
handleBuffer: jest.fn().mockResolvedValue(parsedFile),
|
|
40
|
+
} as unknown as FileHandler;
|
|
41
|
+
|
|
42
|
+
const sdkMock = {
|
|
43
|
+
logger: {
|
|
44
|
+
warn: jest.fn(),
|
|
45
|
+
info: jest.fn(),
|
|
46
|
+
debug: jest.fn(),
|
|
47
|
+
error: jest.fn(),
|
|
48
|
+
verbose: jest.fn(),
|
|
49
|
+
},
|
|
50
|
+
config: {
|
|
51
|
+
dsn: sourceDsn,
|
|
52
|
+
},
|
|
53
|
+
telemetry: {
|
|
54
|
+
increment: jest.fn(),
|
|
55
|
+
},
|
|
56
|
+
sender: {
|
|
57
|
+
documents: jest.fn(),
|
|
58
|
+
},
|
|
59
|
+
files: jest.fn().mockImplementation(() => destinationFileHandlerMock),
|
|
60
|
+
} as unknown as ConnectorSDKInterface<FileConfig>;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
(FileHandler as unknown as jest.Mock).mockImplementation(
|
|
64
|
+
() => fileReaderMock,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
processor = new Processor(
|
|
68
|
+
sdkMock,
|
|
69
|
+
{
|
|
70
|
+
selector: 'test-file-.+\\.csv',
|
|
71
|
+
delimiter: ',',
|
|
72
|
+
action: 'move',
|
|
73
|
+
destinationDsn,
|
|
74
|
+
},
|
|
75
|
+
fileHandlerMock,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should be defined', () => {
|
|
80
|
+
expect(processor).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should give the correct name', () => {
|
|
84
|
+
expect(processor.name).toBe('file-processor-test-file-.+\\.csv');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should process files correctly', async () => {
|
|
88
|
+
await processor.onRun();
|
|
89
|
+
|
|
90
|
+
expect(fileHandlerMock.read).toHaveBeenCalledWith('/test-file-001.csv');
|
|
91
|
+
expect(fileReaderMock.handleBuffer).toHaveBeenCalledWith(
|
|
92
|
+
'/test-file-001.csv',
|
|
93
|
+
readFile.get(),
|
|
94
|
+
[],
|
|
95
|
+
undefined,
|
|
96
|
+
);
|
|
97
|
+
expect(sdkMock.sender.documents).toHaveBeenCalledWith(
|
|
98
|
+
[
|
|
99
|
+
{
|
|
100
|
+
foo: 'bar',
|
|
101
|
+
_filename: 'test-file-001.csv',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
{
|
|
105
|
+
keyField: '_filename',
|
|
106
|
+
collection: 'file_test-file-.+\\.csv',
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
expect(destinationFileHandlerMock.write).toHaveBeenCalledWith(
|
|
110
|
+
'test-file-001.csv',
|
|
111
|
+
readFile,
|
|
112
|
+
);
|
|
113
|
+
expect(fileHandlerMock.delete).toHaveBeenCalledWith('/test-file-001.csv');
|
|
114
|
+
expect(sdkMock.telemetry.increment).toHaveBeenCalledWith(
|
|
115
|
+
'files.processed.count',
|
|
116
|
+
1,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ConnectorSDKInterface,
|
|
5
|
+
FileHandleInterface,
|
|
6
|
+
FileInfo,
|
|
7
|
+
FilesSDKInterface,
|
|
8
|
+
IntervalHandler,
|
|
9
|
+
} from '@transai/connector-runtime-sdk';
|
|
10
|
+
// eslint-disable-next-line @nx/enforce-module-boundaries
|
|
11
|
+
import { FileHandler, ParsedFile } from '@xip-online-data/file-handler';
|
|
12
|
+
|
|
13
|
+
import { FileConfig, FileSelectorConfig } from './types';
|
|
14
|
+
|
|
15
|
+
export class Processor implements IntervalHandler {
|
|
16
|
+
static readonly DEFAULT_INTERVAL_SECONDS = 60;
|
|
17
|
+
|
|
18
|
+
readonly #connectorSDK: ConnectorSDKInterface<FileConfig>;
|
|
19
|
+
|
|
20
|
+
readonly #fileSelector: FileSelectorConfig;
|
|
21
|
+
|
|
22
|
+
readonly #fileHandler: FilesSDKInterface;
|
|
23
|
+
|
|
24
|
+
readonly #destinationFileHandler?: FilesSDKInterface;
|
|
25
|
+
|
|
26
|
+
readonly #fileReader: FileHandler;
|
|
27
|
+
|
|
28
|
+
readonly #fileSelectionRegex: RegExp;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
connectorSDK: ConnectorSDKInterface<FileConfig>,
|
|
32
|
+
fileSelector: FileSelectorConfig,
|
|
33
|
+
fileHandler: FilesSDKInterface,
|
|
34
|
+
) {
|
|
35
|
+
this.#connectorSDK = connectorSDK;
|
|
36
|
+
this.#fileSelector = fileSelector;
|
|
37
|
+
this.#fileHandler = fileHandler;
|
|
38
|
+
this.#fileReader = new FileHandler(fileSelector.delimiter);
|
|
39
|
+
|
|
40
|
+
const { selector } = this.#fileSelector;
|
|
41
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
42
|
+
this.#fileSelectionRegex = new RegExp(
|
|
43
|
+
typeof selector !== 'string' ? selector.pattern : selector,
|
|
44
|
+
typeof selector !== 'string' && selector.flags ? selector.flags : 'i',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (this.#fileSelector.destinationDsn) {
|
|
48
|
+
this.#destinationFileHandler = this.#connectorSDK.files(
|
|
49
|
+
this.#fileSelector.destinationDsn,
|
|
50
|
+
);
|
|
51
|
+
} else if (this.#fileSelector.action === 'move') {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Destination DSN must be provided for 'move' action in file selector: ${this.name}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get name(): string {
|
|
59
|
+
const { selector } = this.#fileSelector;
|
|
60
|
+
return `file-processor-${typeof selector === 'string' ? selector : selector.pattern}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async onRun(): Promise<void> {
|
|
64
|
+
const processedFiles = await this.#listDirectory();
|
|
65
|
+
this.#connectorSDK.telemetry.increment(
|
|
66
|
+
'files.processed.count',
|
|
67
|
+
processedFiles,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #listDirectory(dirPath = '/'): Promise<number> {
|
|
72
|
+
const contents = await this.#fileHandler.list(dirPath);
|
|
73
|
+
const numberOfProcessedFilesList = await Promise.all(
|
|
74
|
+
contents.map(async (fileInfo): Promise<number> => {
|
|
75
|
+
if (fileInfo.type === 'FILE') {
|
|
76
|
+
const processed = await this.#processFile(fileInfo, dirPath);
|
|
77
|
+
return processed ? 1 : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.#fileSelector.recursive === true) {
|
|
81
|
+
return this.#listDirectory(path.join(dirPath, fileInfo.name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Promise.resolve(0);
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return numberOfProcessedFilesList.reduce((acc, curr) => acc + curr, 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #processFile(fileInfo: FileInfo, dirPath: string): Promise<boolean> {
|
|
92
|
+
const filePath = path.join(dirPath, fileInfo.name);
|
|
93
|
+
if (!this.#fileSelectionRegex.test(fileInfo.name)) {
|
|
94
|
+
this.#connectorSDK.logger.verbose(
|
|
95
|
+
`Skipping file at path: ${filePath} as it does not match selector regex`,
|
|
96
|
+
);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.#connectorSDK.logger.debug(`Processing file at path: ${filePath}`);
|
|
101
|
+
let fileContent: FileHandleInterface | undefined;
|
|
102
|
+
let parsedContent: ParsedFile | undefined;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
fileContent = await this.#fileHandler.read(filePath);
|
|
106
|
+
|
|
107
|
+
// Parse file to JSON using FileHandler based on file type
|
|
108
|
+
parsedContent = await this.#fileReader.handleBuffer(
|
|
109
|
+
filePath,
|
|
110
|
+
fileContent.get(),
|
|
111
|
+
this.#fileSelector.optionalHeaders ?? [],
|
|
112
|
+
this.#fileSelector.optionalSettings,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (!parsedContent) {
|
|
116
|
+
this.#connectorSDK.logger.info(
|
|
117
|
+
`Failed to parse file at path: ${filePath}`,
|
|
118
|
+
);
|
|
119
|
+
fileContent.close();
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.#connectorSDK.logger.debug(
|
|
124
|
+
`Parsed file successfully at path: ${filePath}`,
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.#connectorSDK.logger.error(
|
|
128
|
+
`Error parsing file at path: ${filePath}, error: ${error}`,
|
|
129
|
+
);
|
|
130
|
+
fileContent?.close();
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const data = [
|
|
136
|
+
{
|
|
137
|
+
...parsedContent,
|
|
138
|
+
_filename: FileHandler.getFileName(filePath) ?? fileInfo.name,
|
|
139
|
+
} as object,
|
|
140
|
+
];
|
|
141
|
+
const metadata = {
|
|
142
|
+
keyField: '_filename',
|
|
143
|
+
collection: `${this.#connectorSDK.config.datasourceIdentifier ?? 'file'}_${this.#fileSelector.identifier ?? this.#fileSelectionRegex.source}`,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (this.#fileSelector.type === 'metric') {
|
|
147
|
+
await this.#connectorSDK.sender.metricsLegacy(
|
|
148
|
+
data as Array<never>,
|
|
149
|
+
metadata,
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
await this.#connectorSDK.sender.documents(data, metadata);
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.#connectorSDK.logger.error(
|
|
156
|
+
`Error sending parsed file from path: ${filePath}, error: ${error}`,
|
|
157
|
+
);
|
|
158
|
+
fileContent?.close();
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// eslint-disable-next-line default-case
|
|
164
|
+
switch (this.#fileSelector.action) {
|
|
165
|
+
case 'move':
|
|
166
|
+
await this.#destinationFileHandler?.write(fileInfo.name, fileContent);
|
|
167
|
+
await this.#fileHandler.delete(filePath);
|
|
168
|
+
this.#connectorSDK.logger.debug(
|
|
169
|
+
`"Moved" file at path: ${filePath} to ${this.#destinationFileHandler?.pathAsDsn(fileInfo.name)} after processing`,
|
|
170
|
+
);
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
case 'delete':
|
|
174
|
+
await this.#fileHandler.delete(filePath);
|
|
175
|
+
this.#connectorSDK.logger.debug(
|
|
176
|
+
`Deleted file at path: ${filePath} after processing`,
|
|
177
|
+
);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
fileContent?.close();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { BaseConnectorConfig } from '@xip-online-data/types';
|
|
2
|
+
|
|
3
|
+
export interface FileSelectorConfig {
|
|
4
|
+
/**
|
|
5
|
+
* A regex pattern or string to select matching files. If the identifier is not
|
|
6
|
+
* provided, the selector string itself will be used as the identifier.
|
|
7
|
+
*/
|
|
8
|
+
selector:
|
|
9
|
+
| {
|
|
10
|
+
pattern: string;
|
|
11
|
+
flags?: string;
|
|
12
|
+
}
|
|
13
|
+
| string;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The action to take on the file after processing. 'move' to move the file to
|
|
17
|
+
* a different DSN (requires destinationDsn), 'delete' to delete the file, or
|
|
18
|
+
* 'nothing' to leave the file as is (default).
|
|
19
|
+
*/
|
|
20
|
+
action?: 'move' | 'delete' | 'nothing';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* If the action is 'move', the destination DSN where the file should be moved.
|
|
24
|
+
*/
|
|
25
|
+
destinationDsn?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An optional identifier for the file selector, used for the collection.
|
|
29
|
+
*/
|
|
30
|
+
identifier?: string;
|
|
31
|
+
|
|
32
|
+
intervalSeconds?: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Look for files recursively in subdirectories.
|
|
36
|
+
*/
|
|
37
|
+
recursive?: boolean;
|
|
38
|
+
|
|
39
|
+
delimiter?: string;
|
|
40
|
+
optionalHeaders?: Array<string>;
|
|
41
|
+
optionalSettings?: {
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type?: 'metric' | 'document';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FileConfig extends BaseConnectorConfig {
|
|
49
|
+
dsn: string;
|
|
50
|
+
fileSelectors?: Array<FileSelectorConfig>;
|
|
51
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"forceConsistentCasingInFileNames": true,
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noImplicitOverride": true,
|
|
8
|
+
"noImplicitReturns": true,
|
|
9
|
+
"noFallthroughCasesInSwitch": true,
|
|
10
|
+
"noPropertyAccessFromIndexSignature": true
|
|
11
|
+
},
|
|
12
|
+
"files": [],
|
|
13
|
+
"include": [],
|
|
14
|
+
"references": [
|
|
15
|
+
{
|
|
16
|
+
"path": "./tsconfig.lib.json"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "./tsconfig.spec.json"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"types": ["jest", "node"]
|
|
7
|
+
},
|
|
8
|
+
"include": [
|
|
9
|
+
"jest.config.ts",
|
|
10
|
+
"src/**/*.test.ts",
|
|
11
|
+
"src/**/*.spec.ts",
|
|
12
|
+
"src/**/*.d.ts"
|
|
13
|
+
]
|
|
14
|
+
}
|