@directus/storage-driver-s3 9.21.2

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/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client, } from '@aws-sdk/client-s3';
2
+ import { Upload } from '@aws-sdk/lib-storage';
3
+ import { normalizePath } from '@directus/utils';
4
+ import { isReadableStream } from '@directus/utils/node';
5
+ import { join } from 'node:path';
6
+ export class DriverS3 {
7
+ root;
8
+ client;
9
+ bucket;
10
+ acl;
11
+ serverSideEncryption;
12
+ constructor(config) {
13
+ const s3ClientConfig = {
14
+ credentials: {
15
+ accessKeyId: config.key,
16
+ secretAccessKey: config.secret,
17
+ },
18
+ };
19
+ if (config.endpoint) {
20
+ const protocol = config.endpoint.startsWith('https://') ? 'https:' : 'http:';
21
+ const hostname = config.endpoint.replace('https://', '').replace('http://', '');
22
+ s3ClientConfig.endpoint = {
23
+ hostname,
24
+ protocol,
25
+ path: '/',
26
+ };
27
+ s3ClientConfig.forcePathStyle = true;
28
+ }
29
+ this.client = new S3Client(s3ClientConfig);
30
+ this.bucket = config.bucket;
31
+ this.acl = config.acl;
32
+ this.serverSideEncryption = config.serverSideEncryption;
33
+ this.root = config.root ? normalizePath(config.root, { removeLeading: true }) : '';
34
+ }
35
+ fullPath(filepath) {
36
+ return normalizePath(join(this.root, filepath));
37
+ }
38
+ async read(filepath, range) {
39
+ const commandInput = {
40
+ Key: this.fullPath(filepath),
41
+ Bucket: this.bucket,
42
+ };
43
+ if (range) {
44
+ commandInput.Range = `bytes=${range.start ?? ''}-${range.end ?? ''}`;
45
+ }
46
+ const { Body: stream } = await this.client.send(new GetObjectCommand(commandInput));
47
+ if (!stream || !isReadableStream(stream)) {
48
+ throw new Error(`No stream returned for file "${filepath}"`);
49
+ }
50
+ return stream;
51
+ }
52
+ async stat(filepath) {
53
+ const { ContentLength, LastModified } = await this.client.send(new HeadObjectCommand({
54
+ Key: this.fullPath(filepath),
55
+ Bucket: this.bucket,
56
+ }));
57
+ return {
58
+ size: ContentLength,
59
+ modified: LastModified,
60
+ };
61
+ }
62
+ async exists(filepath) {
63
+ try {
64
+ await this.stat(filepath);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ async move(src, dest) {
72
+ await this.copy(src, dest);
73
+ await this.delete(src);
74
+ }
75
+ async copy(src, dest) {
76
+ const params = {
77
+ Key: this.fullPath(dest),
78
+ Bucket: this.bucket,
79
+ CopySource: `/${this.bucket}/${this.fullPath(src)}`,
80
+ };
81
+ if (this.serverSideEncryption) {
82
+ params.ServerSideEncryption = this.serverSideEncryption;
83
+ }
84
+ if (this.acl) {
85
+ params.ACL = this.acl;
86
+ }
87
+ await this.client.send(new CopyObjectCommand(params));
88
+ }
89
+ async write(filepath, content, type) {
90
+ const params = {
91
+ Key: this.fullPath(filepath),
92
+ Body: content,
93
+ Bucket: this.bucket,
94
+ };
95
+ if (type) {
96
+ params.ContentType = type;
97
+ }
98
+ if (this.acl) {
99
+ params.ACL = this.acl;
100
+ }
101
+ if (this.serverSideEncryption) {
102
+ params.ServerSideEncryption = this.serverSideEncryption;
103
+ }
104
+ const upload = new Upload({
105
+ client: this.client,
106
+ params,
107
+ });
108
+ await upload.done();
109
+ }
110
+ async delete(filepath) {
111
+ await this.client.send(new DeleteObjectCommand({ Key: this.fullPath(filepath), Bucket: this.bucket }));
112
+ }
113
+ async *list(prefix = '') {
114
+ let continuationToken = undefined;
115
+ do {
116
+ const listObjectsV2CommandInput = {
117
+ Bucket: this.bucket,
118
+ Prefix: this.fullPath(prefix),
119
+ MaxKeys: 1000,
120
+ };
121
+ if (continuationToken) {
122
+ listObjectsV2CommandInput.ContinuationToken = continuationToken;
123
+ }
124
+ const response = await this.client.send(new ListObjectsV2Command(listObjectsV2CommandInput));
125
+ continuationToken = response.NextContinuationToken;
126
+ if (response.Contents) {
127
+ for (const file of response.Contents) {
128
+ if (file.Key) {
129
+ yield file.Key.substring(this.root.length);
130
+ }
131
+ }
132
+ }
133
+ } while (continuationToken);
134
+ }
135
+ }
136
+ export default DriverS3;
@@ -0,0 +1,471 @@
1
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client, } from '@aws-sdk/client-s3';
2
+ import { Upload } from '@aws-sdk/lib-storage';
3
+ import { normalizePath } from '@directus/utils';
4
+ import { isReadableStream } from '@directus/utils/node';
5
+ import { randAlphaNumeric, randDirectoryPath, randDomainName, randFilePath, randFileType, randGitBranch as randBucket, randGitShortSha as randUnique, randNumber, randPastDate, randText, randWord, } from '@ngneat/falso';
6
+ import { join } from 'node:path';
7
+ import { PassThrough, Readable } from 'node:stream';
8
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
9
+ import { DriverS3 } from './index.js';
10
+ vi.mock('@directus/utils/node');
11
+ vi.mock('@directus/utils');
12
+ vi.mock('@aws-sdk/client-s3');
13
+ vi.mock('@aws-sdk/lib-storage');
14
+ vi.mock('node:path');
15
+ let sample;
16
+ let driver;
17
+ beforeEach(() => {
18
+ sample = {
19
+ config: {
20
+ key: randAlphaNumeric({ length: 20 }).join(''),
21
+ secret: randAlphaNumeric({ length: 40 }).join(''),
22
+ bucket: randBucket(),
23
+ acl: randWord(),
24
+ serverSideEncryption: randWord(),
25
+ root: randDirectoryPath(),
26
+ endpoint: randDomainName(),
27
+ },
28
+ path: {
29
+ input: randUnique() + randFilePath(),
30
+ inputFull: randUnique() + randFilePath(),
31
+ src: randUnique() + randFilePath(),
32
+ srcFull: randUnique() + randFilePath(),
33
+ dest: randUnique() + randFilePath(),
34
+ destFull: randUnique() + randFilePath(),
35
+ },
36
+ range: {
37
+ start: randNumber(),
38
+ end: randNumber(),
39
+ },
40
+ stream: new PassThrough(),
41
+ text: randText(),
42
+ file: {
43
+ type: randFileType(),
44
+ size: randNumber(),
45
+ modified: randPastDate(),
46
+ },
47
+ };
48
+ driver = new DriverS3({
49
+ key: sample.config.key,
50
+ secret: sample.config.secret,
51
+ bucket: sample.config.bucket,
52
+ });
53
+ driver['fullPath'] = vi.fn().mockImplementation((input) => {
54
+ if (input === sample.path.src)
55
+ return sample.path.srcFull;
56
+ if (input === sample.path.dest)
57
+ return sample.path.destFull;
58
+ if (input === sample.path.input)
59
+ return sample.path.inputFull;
60
+ return '';
61
+ });
62
+ });
63
+ afterEach(() => {
64
+ vi.resetAllMocks();
65
+ });
66
+ describe('#constructor', () => {
67
+ test('Creates S3Client with key / secret configuration', () => {
68
+ expect(S3Client).toHaveBeenCalledWith({
69
+ credentials: {
70
+ accessKeyId: sample.config.key,
71
+ secretAccessKey: sample.config.secret,
72
+ },
73
+ });
74
+ expect(driver['client']).toBeInstanceOf(S3Client);
75
+ });
76
+ test('Sets private bucket reference based on config', () => {
77
+ expect(driver['bucket']).toBe(sample.config.bucket);
78
+ });
79
+ test('Sets private acl reference based on config', () => {
80
+ const driver = new DriverS3({
81
+ key: sample.config.key,
82
+ secret: sample.config.secret,
83
+ bucket: sample.config.bucket,
84
+ acl: sample.config.acl,
85
+ });
86
+ expect(driver['acl']).toBe(sample.config.acl);
87
+ });
88
+ test('Sets private serverSideEncryption reference based on config', () => {
89
+ const driver = new DriverS3({
90
+ key: sample.config.key,
91
+ secret: sample.config.secret,
92
+ bucket: sample.config.bucket,
93
+ serverSideEncryption: sample.config.serverSideEncryption,
94
+ });
95
+ expect(driver['serverSideEncryption']).toBe(sample.config.serverSideEncryption);
96
+ });
97
+ test('Defaults root to empty string', () => {
98
+ expect(driver['root']).toBe('');
99
+ });
100
+ test('Sets http endpoints', () => {
101
+ const sampleDomain = randDomainName();
102
+ const sampleHttpEndpoint = `http://${sampleDomain}`;
103
+ new DriverS3({
104
+ key: sample.config.key,
105
+ secret: sample.config.secret,
106
+ bucket: sample.config.bucket,
107
+ endpoint: sampleHttpEndpoint,
108
+ });
109
+ expect(S3Client).toHaveBeenCalledWith({
110
+ forcePathStyle: true,
111
+ endpoint: {
112
+ hostname: sampleDomain,
113
+ protocol: 'http:',
114
+ path: '/',
115
+ },
116
+ credentials: {
117
+ accessKeyId: sample.config.key,
118
+ secretAccessKey: sample.config.secret,
119
+ },
120
+ });
121
+ });
122
+ test('Sets https endpoints', () => {
123
+ const sampleDomain = randDomainName();
124
+ const sampleHttpEndpoint = `https://${sampleDomain}`;
125
+ new DriverS3({
126
+ key: sample.config.key,
127
+ secret: sample.config.secret,
128
+ bucket: sample.config.bucket,
129
+ endpoint: sampleHttpEndpoint,
130
+ });
131
+ expect(S3Client).toHaveBeenCalledWith({
132
+ forcePathStyle: true,
133
+ endpoint: {
134
+ hostname: sampleDomain,
135
+ protocol: 'https:',
136
+ path: '/',
137
+ },
138
+ credentials: {
139
+ accessKeyId: sample.config.key,
140
+ secretAccessKey: sample.config.secret,
141
+ },
142
+ });
143
+ });
144
+ test('Normalizes config path when root is given', () => {
145
+ const mockRoot = randDirectoryPath();
146
+ vi.mocked(normalizePath).mockReturnValue(mockRoot);
147
+ const driver = new DriverS3({
148
+ key: sample.config.key,
149
+ secret: sample.config.secret,
150
+ bucket: sample.config.bucket,
151
+ root: sample.config.root,
152
+ });
153
+ expect(normalizePath).toHaveBeenCalledWith(sample.config.root, { removeLeading: true });
154
+ expect(driver['root']).toBe(mockRoot);
155
+ });
156
+ });
157
+ describe('#fullPath', () => {
158
+ test('Returns normalized joined path', () => {
159
+ const driver = new DriverS3({
160
+ key: sample.config.key,
161
+ secret: sample.config.secret,
162
+ bucket: sample.config.bucket,
163
+ });
164
+ vi.mocked(join).mockReturnValue(sample.path.inputFull);
165
+ vi.mocked(normalizePath).mockReturnValue(sample.path.inputFull);
166
+ driver['root'] = sample.config.root;
167
+ const result = driver['fullPath'](sample.path.input);
168
+ expect(join).toHaveBeenCalledWith(sample.config.root, sample.path.input);
169
+ expect(normalizePath).toHaveBeenCalledWith(sample.path.inputFull);
170
+ expect(result).toBe(sample.path.inputFull);
171
+ });
172
+ });
173
+ describe('#read', () => {
174
+ beforeEach(() => {
175
+ vi.mocked(driver['client'].send).mockReturnValue({ Body: new Readable() });
176
+ vi.mocked(isReadableStream).mockReturnValue(true);
177
+ });
178
+ test('Uses fullPath key / bucket in command input', async () => {
179
+ await driver.read(sample.path.input);
180
+ expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
181
+ expect(GetObjectCommand).toHaveBeenCalledWith({
182
+ Key: sample.path.inputFull,
183
+ Bucket: sample.config.bucket,
184
+ });
185
+ });
186
+ test('Optionally allows setting start range offset', async () => {
187
+ await driver.read(sample.path.input, { start: sample.range.start });
188
+ expect(GetObjectCommand).toHaveBeenCalledWith({
189
+ Key: sample.path.inputFull,
190
+ Bucket: sample.config.bucket,
191
+ Range: `bytes=${sample.range.start}-`,
192
+ });
193
+ });
194
+ test('Optionally allows setting end range offset', async () => {
195
+ await driver.read(sample.path.input, { end: sample.range.end });
196
+ expect(GetObjectCommand).toHaveBeenCalledWith({
197
+ Key: sample.path.inputFull,
198
+ Bucket: sample.config.bucket,
199
+ Range: `bytes=-${sample.range.end}`,
200
+ });
201
+ });
202
+ test('Optionally allows setting start and end range offset', async () => {
203
+ await driver.read(sample.path.input, sample.range);
204
+ expect(GetObjectCommand).toHaveBeenCalledWith({
205
+ Key: sample.path.inputFull,
206
+ Bucket: sample.config.bucket,
207
+ Range: `bytes=${sample.range.start}-${sample.range.end}`,
208
+ });
209
+ });
210
+ test('Throws an error when no stream is returned', async () => {
211
+ vi.mocked(driver['client'].send).mockReturnValue({ Body: undefined });
212
+ try {
213
+ await driver.read(sample.path.input, sample.range);
214
+ }
215
+ catch (err) {
216
+ expect(err).toBeInstanceOf(Error);
217
+ expect(err.message).toBe(`No stream returned for file "${sample.path.input}"`);
218
+ }
219
+ });
220
+ test('Throws an error when returned stream is not a readable stream', async () => {
221
+ vi.mocked(isReadableStream).mockReturnValue(false);
222
+ expect(driver.read(sample.path.input, sample.range)).rejects.toThrowError(new Error(`No stream returned for file "${sample.path.input}"`));
223
+ });
224
+ test('Returns stream from S3 client', async () => {
225
+ const mockGetObjectCommand = {};
226
+ vi.mocked(driver['client'].send).mockReturnValue({ Body: sample.stream });
227
+ vi.mocked(GetObjectCommand).mockReturnValue(mockGetObjectCommand);
228
+ const stream = await driver.read(sample.path.input, sample.range);
229
+ expect(driver['client'].send).toHaveBeenCalledWith(mockGetObjectCommand);
230
+ expect(stream).toBe(sample.stream);
231
+ });
232
+ });
233
+ describe('#stat', () => {
234
+ beforeEach(() => {
235
+ vi.mocked(driver['client'].send).mockResolvedValue({
236
+ ContentLength: sample.file.size,
237
+ LastModified: sample.file.modified,
238
+ });
239
+ });
240
+ test('Uses HeadObjectCommand with fullPath', async () => {
241
+ await driver.stat(sample.path.input);
242
+ expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
243
+ expect(HeadObjectCommand).toHaveBeenCalledWith({
244
+ Key: sample.path.inputFull,
245
+ Bucket: sample.config.bucket,
246
+ });
247
+ });
248
+ test('Calls #send with HeadObjectCommand', async () => {
249
+ const mockHeadObjectCommand = {};
250
+ vi.mocked(HeadObjectCommand).mockReturnValue(mockHeadObjectCommand);
251
+ await driver.stat(sample.path.input);
252
+ expect(driver['client'].send).toHaveBeenCalledWith(mockHeadObjectCommand);
253
+ });
254
+ test('Returns size/modified from returned send data', async () => {
255
+ const result = await driver.stat(sample.path.input);
256
+ expect(result).toStrictEqual({
257
+ size: sample.file.size,
258
+ modified: sample.file.modified,
259
+ });
260
+ });
261
+ });
262
+ describe('#exists', () => {
263
+ beforeEach(() => {
264
+ driver.stat = vi.fn();
265
+ });
266
+ test('Returns true if stat returns the stats', async () => {
267
+ vi.mocked(driver.stat).mockResolvedValue({ size: sample.file.size, modified: sample.file.modified });
268
+ const exists = await driver.exists(sample.path.input);
269
+ expect(exists).toBe(true);
270
+ });
271
+ test('Returns false if stat throws an error', async () => {
272
+ vi.mocked(driver.stat).mockRejectedValue(new Error());
273
+ const exists = await driver.exists(sample.path.input);
274
+ expect(exists).toBe(false);
275
+ });
276
+ });
277
+ describe('#move', () => {
278
+ beforeEach(async () => {
279
+ driver.copy = vi.fn();
280
+ driver.delete = vi.fn();
281
+ await driver.move(sample.path.src, sample.path.dest);
282
+ });
283
+ test('Calls copy with given src and dest', async () => {
284
+ expect(driver.copy).toHaveBeenCalledWith(sample.path.src, sample.path.dest);
285
+ });
286
+ test('Calls delete on successful copy', async () => {
287
+ expect(driver.delete).toHaveBeenCalledWith(sample.path.src);
288
+ });
289
+ });
290
+ describe('#copy', () => {
291
+ test('Constructs params object based on config', async () => {
292
+ await driver.copy(sample.path.src, sample.path.dest);
293
+ expect(CopyObjectCommand).toHaveBeenCalledWith({
294
+ Key: sample.path.destFull,
295
+ Bucket: sample.config.bucket,
296
+ CopySource: `/${sample.config.bucket}/${sample.path.srcFull}`,
297
+ });
298
+ });
299
+ test('Optionally sets ServerSideEncryption', async () => {
300
+ driver['serverSideEncryption'] = sample.config.serverSideEncryption;
301
+ await driver.copy(sample.path.src, sample.path.dest);
302
+ expect(CopyObjectCommand).toHaveBeenCalledWith({
303
+ Key: sample.path.destFull,
304
+ Bucket: sample.config.bucket,
305
+ CopySource: `/${sample.config.bucket}/${sample.path.srcFull}`,
306
+ ServerSideEncryption: sample.config.serverSideEncryption,
307
+ });
308
+ });
309
+ test('Optionally sets ACL', async () => {
310
+ driver['acl'] = sample.config.acl;
311
+ await driver.copy(sample.path.src, sample.path.dest);
312
+ expect(CopyObjectCommand).toHaveBeenCalledWith({
313
+ Key: sample.path.destFull,
314
+ Bucket: sample.config.bucket,
315
+ CopySource: `/${sample.config.bucket}/${sample.path.srcFull}`,
316
+ ACL: sample.config.acl,
317
+ });
318
+ });
319
+ test('Executes CopyObjectCommand', async () => {
320
+ const mockCommand = {};
321
+ vi.mocked(CopyObjectCommand).mockReturnValue(mockCommand);
322
+ await driver.copy(sample.path.src, sample.path.dest);
323
+ expect(driver['client'].send).toHaveBeenCalledWith(mockCommand);
324
+ });
325
+ });
326
+ describe('#write', () => {
327
+ test('Passes streams to body as is', async () => {
328
+ await driver.write(sample.path.input, sample.stream);
329
+ expect(Upload).toHaveBeenCalledWith({
330
+ client: driver['client'],
331
+ params: {
332
+ Key: sample.path.inputFull,
333
+ Bucket: sample.config.bucket,
334
+ Body: sample.stream,
335
+ },
336
+ });
337
+ });
338
+ test('Optionally sets ContentType', async () => {
339
+ await driver.write(sample.path.input, sample.stream, sample.file.type);
340
+ expect(Upload).toHaveBeenCalledWith({
341
+ client: driver['client'],
342
+ params: {
343
+ Key: sample.path.inputFull,
344
+ Bucket: sample.config.bucket,
345
+ Body: sample.stream,
346
+ ContentType: sample.file.type,
347
+ },
348
+ });
349
+ });
350
+ test('Optionally sets ServerSideEncryption', async () => {
351
+ driver['serverSideEncryption'] = sample.config.serverSideEncryption;
352
+ await driver.write(sample.path.input, sample.stream);
353
+ expect(Upload).toHaveBeenCalledWith({
354
+ client: driver['client'],
355
+ params: {
356
+ Key: sample.path.inputFull,
357
+ Bucket: sample.config.bucket,
358
+ Body: sample.stream,
359
+ ServerSideEncryption: sample.config.serverSideEncryption,
360
+ },
361
+ });
362
+ });
363
+ test('Optionally sets ACL', async () => {
364
+ driver['acl'] = sample.config.acl;
365
+ await driver.write(sample.path.input, sample.stream);
366
+ expect(Upload).toHaveBeenCalledWith({
367
+ client: driver['client'],
368
+ params: {
369
+ Key: sample.path.inputFull,
370
+ Bucket: sample.config.bucket,
371
+ Body: sample.stream,
372
+ ACL: sample.config.acl,
373
+ },
374
+ });
375
+ });
376
+ test('Waits for upload to be done', async () => {
377
+ const mockUpload = { done: vi.fn() };
378
+ vi.mocked(Upload).mockReturnValue(mockUpload);
379
+ await driver.write(sample.path.input, sample.stream);
380
+ expect(mockUpload.done).toHaveBeenCalledOnce();
381
+ });
382
+ });
383
+ describe('#delete', () => {
384
+ test('Constructs params based on input', async () => {
385
+ await driver.delete(sample.path.input);
386
+ expect(DeleteObjectCommand).toHaveBeenCalledWith({
387
+ Key: sample.path.inputFull,
388
+ Bucket: sample.config.bucket,
389
+ });
390
+ });
391
+ test('Executes DeleteObjectCommand', async () => {
392
+ const mockDeleteObjectCommand = {};
393
+ vi.mocked(DeleteObjectCommand).mockReturnValue(mockDeleteObjectCommand);
394
+ await driver.delete(sample.path.input);
395
+ expect(driver['client'].send).toHaveBeenCalledWith(mockDeleteObjectCommand);
396
+ });
397
+ });
398
+ describe('#list', () => {
399
+ test('Constructs list objects params based on input prefix', async () => {
400
+ vi.mocked(driver['client'].send).mockResolvedValue({});
401
+ await driver.list(sample.path.input).next();
402
+ expect(ListObjectsV2Command).toHaveBeenCalledWith({
403
+ Bucket: sample.config.bucket,
404
+ Prefix: sample.path.inputFull,
405
+ MaxKeys: 1000,
406
+ });
407
+ });
408
+ test('Calls send with the command', async () => {
409
+ const mockListObjectsV2Command = {};
410
+ vi.mocked(ListObjectsV2Command).mockReturnValue(mockListObjectsV2Command);
411
+ vi.mocked(driver['client'].send).mockResolvedValue({});
412
+ await driver.list(sample.path.input).next();
413
+ expect(driver['client'].send).toHaveBeenCalledWith(mockListObjectsV2Command);
414
+ });
415
+ test('Yields file Key omitting root', async () => {
416
+ const sampleRoot = randDirectoryPath();
417
+ const sampleFile = randFilePath();
418
+ const sampleFull = `${sampleRoot}${sampleFile}`;
419
+ vi.mocked(driver['client'].send).mockResolvedValue({
420
+ Contents: [
421
+ {
422
+ Key: sampleFull,
423
+ },
424
+ ],
425
+ });
426
+ driver['root'] = sampleRoot;
427
+ const iterator = driver.list(sample.path.input);
428
+ const output = [];
429
+ for await (const filepath of iterator) {
430
+ output.push(filepath);
431
+ }
432
+ expect(output).toStrictEqual([sampleFile]);
433
+ });
434
+ test('Continuously fetches until all pages are returned', async () => {
435
+ vi.mocked(driver['client'].send)
436
+ .mockResolvedValueOnce({
437
+ NextContinuationToken: randWord(),
438
+ Contents: [
439
+ {
440
+ Key: randFilePath(),
441
+ },
442
+ {
443
+ Key: randFilePath(),
444
+ },
445
+ ],
446
+ })
447
+ .mockResolvedValueOnce({
448
+ NextContinuationToken: randWord(),
449
+ Contents: [
450
+ {
451
+ Key: randFilePath(),
452
+ },
453
+ ],
454
+ })
455
+ .mockResolvedValueOnce({
456
+ NextContinuationToken: undefined,
457
+ Contents: [
458
+ {
459
+ Key: randFilePath(),
460
+ },
461
+ ],
462
+ });
463
+ const iterator = driver.list(sample.path.input);
464
+ const output = [];
465
+ for await (const filepath of iterator) {
466
+ output.push(filepath);
467
+ }
468
+ expect(driver['client'].send).toHaveBeenCalledTimes(3);
469
+ expect(output.length).toBe(4);
470
+ });
471
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@directus/storage-driver-s3",
3
+ "version": "9.21.2",
4
+ "type": "module",
5
+ "description": "S3 file storage abstraction for `@directus/storage`",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/directus/directus.git",
9
+ "directory": "packages/storage-driver-s3"
10
+ },
11
+ "funding": "https://github.com/directus/directus?sponsor=1",
12
+ "license": "GPL-3.0",
13
+ "author": "Rijk van Zanten <rijkvanzanten@me.com>",
14
+ "exports": {
15
+ ".": "./dist/index.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "main": "dist/index.js",
19
+ "files": [
20
+ "dist",
21
+ "!**/*.d.ts?(.map)"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@aws-sdk/client-s3": "3.224.0",
28
+ "@aws-sdk/lib-storage": "3.234.0",
29
+ "@directus/storage": "9.21.2",
30
+ "@directus/utils": "9.21.2"
31
+ },
32
+ "devDependencies": {
33
+ "@directus/tsconfig": "0.0.6",
34
+ "@ngneat/falso": "6.3.0",
35
+ "@vitest/coverage-c8": "0.25.3",
36
+ "typescript": "4.9.3",
37
+ "vitest": "0.25.3"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc --build",
41
+ "dev": "tsc --watch",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "test:coverage": "vitest run --coverage"
45
+ }
46
+ }
package/readme.md ADDED
@@ -0,0 +1,3 @@
1
+ # `@directus/storage-driver-s3`
2
+
3
+ S3 file storage driver for `@directus/storage`