Skip to content

Commit 174b191

Browse files
authored
Add util to download file over SFTP (#4032)
1 parent 4737106 commit 174b191

File tree

3 files changed

+226
-0
lines changed

3 files changed

+226
-0
lines changed

.changeset/grumpy-buses-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@chainlink/ftse-sftp-adapter': patch
3+
---
4+
5+
Add utility that's not used yet.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { makeLogger } from '@chainlink/external-adapter-framework/util'
2+
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
3+
import path from 'path'
4+
import SftpClient, { ConnectOptions, FileInfo } from 'ssh2-sftp-client'
5+
6+
const logger = makeLogger('FTSE SFTP Utils')
7+
8+
// Downloads the single file matching the provided regex from the specified
9+
// directory over SFTP.
10+
export const getFileContentsFromFileRegex = async ({
11+
connectOptions,
12+
directory,
13+
filenameRegex,
14+
}: {
15+
connectOptions: ConnectOptions
16+
directory: string
17+
filenameRegex: RegExp
18+
}): Promise<{
19+
filename: string
20+
fileContent: string | NodeJS.WritableStream | Buffer
21+
}> => {
22+
const client = new SftpClient()
23+
try {
24+
await client.connect(connectOptions)
25+
logger.debug('Successfully connected to SFTP server')
26+
27+
const filename = await getFilenameFromRegex({
28+
client,
29+
directory,
30+
filenameRegex,
31+
})
32+
const filePath = path.join(directory, filename)
33+
34+
return {
35+
filename,
36+
fileContent: await client.get(filePath),
37+
}
38+
} finally {
39+
client.end()
40+
logger.debug('SFTP connection closed')
41+
}
42+
}
43+
44+
export const getFilenameFromRegex = async ({
45+
client,
46+
directory,
47+
filenameRegex,
48+
}: {
49+
client: SftpClient
50+
directory: string
51+
filenameRegex: RegExp
52+
}): Promise<string> => {
53+
const fileList = await client.list(directory)
54+
// Filter files based on the regex pattern
55+
const matchingFiles = fileList
56+
.map((file: FileInfo) => file.name)
57+
.filter((fileName: string) => filenameRegex.test(fileName))
58+
59+
if (matchingFiles.length === 0) {
60+
throw new AdapterInputError({
61+
statusCode: 500,
62+
message: `No files matching pattern ${filenameRegex} found in directory '${directory}'`,
63+
})
64+
} else if (matchingFiles.length > 1) {
65+
throw new AdapterInputError({
66+
statusCode: 500,
67+
message: `Multiple files matching pattern ${filenameRegex} found in directory '${directory}': ${matchingFiles.join(
68+
', ',
69+
)}`,
70+
})
71+
}
72+
73+
return matchingFiles[0]
74+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util'
2+
import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils'
3+
import { getFileContentsFromFileRegex } from '../../src/transport/utils'
4+
5+
const mockSftpClient = makeStub('mockSftpClient', {
6+
connect: jest.fn(),
7+
list: jest.fn(),
8+
get: jest.fn(),
9+
end: jest.fn(),
10+
})
11+
12+
jest.mock(
13+
'ssh2-sftp-client',
14+
() =>
15+
function () {
16+
return mockSftpClient
17+
},
18+
)
19+
20+
LoggerFactoryProvider.set()
21+
22+
describe('Transport utils', () => {
23+
const host = 'sftp.test.com'
24+
const port = 22
25+
const username = 'testuser'
26+
const password = 'testpassword'
27+
const connectOptions = {
28+
host,
29+
port,
30+
username,
31+
password,
32+
}
33+
const directory = '/some/directory'
34+
const filenameRegex = /^test_file_\d{3}\.csv/
35+
const expectedFilename = 'test_file_123.csv'
36+
const expectedFilename2 = 'test_file_456.csv'
37+
const expectedFullPath = '/some/directory/test_file_123.csv'
38+
const differentFilename1 = 'test_file_9999.csv'
39+
const differentFilename2 = 'differentfile_9999.csv'
40+
const differentFilename3 = 'test_file_123.txt'
41+
const expectedContent = 'some,file,content'
42+
43+
beforeEach(async () => {
44+
jest.resetAllMocks()
45+
jest.useFakeTimers()
46+
47+
mockSftpClient.get.mockResolvedValue(Buffer.from(expectedContent))
48+
})
49+
50+
describe('getFileContentsFromFileRegex', () => {
51+
it('should return filename and content', async () => {
52+
mockSftpClient.list.mockResolvedValue([{ name: expectedFilename }])
53+
54+
const { filename, fileContent } = await getFileContentsFromFileRegex({
55+
connectOptions,
56+
directory,
57+
filenameRegex,
58+
})
59+
60+
expect(filename).toBe(expectedFilename)
61+
expect(fileContent.toString()).toBe(expectedContent)
62+
63+
expect(mockSftpClient.connect).toHaveBeenCalledWith(connectOptions)
64+
expect(mockSftpClient.connect).toHaveBeenCalledTimes(1)
65+
expect(mockSftpClient.list).toHaveBeenCalledWith(directory)
66+
expect(mockSftpClient.list).toHaveBeenCalledTimes(1)
67+
expect(mockSftpClient.get).toHaveBeenCalledWith(expectedFullPath)
68+
expect(mockSftpClient.get).toHaveBeenCalledTimes(1)
69+
expect(mockSftpClient.end).toHaveBeenCalledTimes(1)
70+
})
71+
72+
it('should load correct file among multiple', async () => {
73+
mockSftpClient.list.mockResolvedValue([
74+
{ name: differentFilename1 },
75+
{ name: differentFilename2 },
76+
{ name: expectedFilename },
77+
{ name: differentFilename3 },
78+
])
79+
80+
const { filename, fileContent } = await getFileContentsFromFileRegex({
81+
connectOptions,
82+
directory,
83+
filenameRegex,
84+
})
85+
86+
expect(filename).toBe(expectedFilename)
87+
expect(fileContent.toString()).toBe(expectedContent)
88+
expect(mockSftpClient.get).toHaveBeenCalledWith(expectedFullPath)
89+
expect(mockSftpClient.get).toHaveBeenCalledTimes(1)
90+
})
91+
92+
it('should throw if file not found', async () => {
93+
mockSftpClient.list.mockResolvedValue([
94+
{ name: differentFilename1 },
95+
{ name: differentFilename2 },
96+
{ name: differentFilename3 },
97+
])
98+
99+
await expect(() =>
100+
getFileContentsFromFileRegex({
101+
connectOptions,
102+
directory,
103+
filenameRegex,
104+
}),
105+
).rejects.toThrow(
106+
`No files matching pattern ${filenameRegex} found in directory '${directory}'`,
107+
)
108+
109+
expect(mockSftpClient.end).toHaveBeenCalledTimes(1)
110+
})
111+
112+
it('should throw if multiple files match', async () => {
113+
mockSftpClient.list.mockResolvedValue([
114+
{ name: differentFilename1 },
115+
{ name: expectedFilename },
116+
{ name: expectedFilename2 },
117+
])
118+
119+
await expect(() =>
120+
getFileContentsFromFileRegex({
121+
connectOptions,
122+
directory,
123+
filenameRegex,
124+
}),
125+
).rejects.toThrow(
126+
`Multiple files matching pattern ${filenameRegex} found in directory '${directory}': test_file_123.csv, test_file_456.csv`,
127+
)
128+
129+
expect(mockSftpClient.end).toHaveBeenCalledTimes(1)
130+
})
131+
132+
it('should end the connection if the client throws an error', async () => {
133+
const errorMessage = 'error listing files'
134+
mockSftpClient.list.mockRejectedValue(new Error(errorMessage))
135+
136+
await expect(() =>
137+
getFileContentsFromFileRegex({
138+
connectOptions,
139+
directory,
140+
filenameRegex,
141+
}),
142+
).rejects.toThrow(errorMessage)
143+
144+
expect(mockSftpClient.end).toHaveBeenCalledTimes(1)
145+
})
146+
})
147+
})

0 commit comments

Comments
 (0)