@directus/storage-driver-gcs 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,59 @@
1
+ import { normalizePath } from '@directus/utils';
2
+ import { Storage } from '@google-cloud/storage';
3
+ import { join } from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+ export class DriverGCS {
6
+ root;
7
+ bucket;
8
+ constructor(config) {
9
+ const { bucket, root, ...storageOptions } = config;
10
+ this.root = root ? normalizePath(root, { removeLeading: true }) : '';
11
+ const storage = new Storage(storageOptions);
12
+ this.bucket = storage.bucket(bucket);
13
+ }
14
+ fullPath(filepath) {
15
+ return normalizePath(join(this.root, filepath));
16
+ }
17
+ file(filepath) {
18
+ return this.bucket.file(this.fullPath(filepath));
19
+ }
20
+ async read(filepath, range) {
21
+ return this.file(this.fullPath(filepath)).createReadStream(range);
22
+ }
23
+ async write(filepath, content) {
24
+ const file = this.file(this.fullPath(filepath));
25
+ const stream = file.createWriteStream({ resumable: false });
26
+ await pipeline(content, stream);
27
+ }
28
+ async delete(filepath) {
29
+ await this.file(this.fullPath(filepath)).delete();
30
+ }
31
+ async stat(filepath) {
32
+ const [{ size, updated }] = await this.file(this.fullPath(filepath)).getMetadata();
33
+ return { size, modified: updated };
34
+ }
35
+ async exists(filepath) {
36
+ return (await this.file(this.fullPath(filepath)).exists())[0];
37
+ }
38
+ async move(src, dest) {
39
+ await this.file(this.fullPath(src)).move(this.file(this.fullPath(dest)));
40
+ }
41
+ async copy(src, dest) {
42
+ await this.file(this.fullPath(src)).copy(this.file(this.fullPath(dest)));
43
+ }
44
+ async *list(prefix = '') {
45
+ let query = {
46
+ prefix: this.fullPath(prefix),
47
+ autoPaginate: false,
48
+ maxResults: 500,
49
+ };
50
+ while (query) {
51
+ const [files, nextQuery] = await this.bucket.getFiles(query);
52
+ for (const file of files) {
53
+ yield file.name.substring(this.root.length);
54
+ }
55
+ query = nextQuery;
56
+ }
57
+ }
58
+ }
59
+ export default DriverGCS;
@@ -0,0 +1,327 @@
1
+ import { normalizePath } from '@directus/utils';
2
+ import { Storage } from '@google-cloud/storage';
3
+ import { randDirectoryPath, randFilePath, randFileType, randGitBranch as randBucket, randGitShortSha as randUnique, randNumber, randPastDate, randText, randUrl, } from '@ngneat/falso';
4
+ import { join } from 'node:path';
5
+ import { PassThrough } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
8
+ import { DriverGCS } from './index.js';
9
+ vi.mock('@directus/utils/node');
10
+ vi.mock('@directus/utils');
11
+ vi.mock('@google-cloud/storage');
12
+ vi.mock('node:path');
13
+ vi.mock('node:stream/promises');
14
+ let sample;
15
+ let driver;
16
+ beforeEach(() => {
17
+ sample = {
18
+ config: {
19
+ root: randDirectoryPath(),
20
+ apiEndpoint: randUrl(),
21
+ bucket: randBucket(),
22
+ },
23
+ path: {
24
+ input: randUnique() + randFilePath(),
25
+ inputFull: randUnique() + randFilePath(),
26
+ src: randFilePath(),
27
+ srcFull: randFilePath(),
28
+ dest: randFilePath(),
29
+ destFull: randFilePath(),
30
+ },
31
+ range: {
32
+ start: randNumber(),
33
+ end: randNumber(),
34
+ },
35
+ stream: new PassThrough(),
36
+ text: randText(),
37
+ file: {
38
+ type: randFileType(),
39
+ size: randNumber(),
40
+ modified: randPastDate(),
41
+ },
42
+ };
43
+ driver = new DriverGCS({
44
+ bucket: sample.config.bucket,
45
+ });
46
+ driver['fullPath'] = vi.fn().mockImplementation((input) => {
47
+ if (input === sample.path.src)
48
+ return sample.path.srcFull;
49
+ if (input === sample.path.dest)
50
+ return sample.path.destFull;
51
+ if (input === sample.path.input)
52
+ return sample.path.inputFull;
53
+ return '';
54
+ });
55
+ });
56
+ afterEach(() => {
57
+ vi.resetAllMocks();
58
+ });
59
+ describe('#constructor', () => {
60
+ test('Defaults root path to empty string', () => {
61
+ expect(driver['root']).toBe('');
62
+ });
63
+ test('Normalizes config path when root is given', () => {
64
+ new DriverGCS({
65
+ bucket: sample.config.bucket,
66
+ root: sample.config.root,
67
+ });
68
+ expect(normalizePath).toHaveBeenCalledWith(sample.config.root, { removeLeading: true });
69
+ });
70
+ test('Instantiates Storage object with config options', () => {
71
+ new DriverGCS({
72
+ bucket: sample.config.bucket,
73
+ apiEndpoint: sample.config.apiEndpoint,
74
+ });
75
+ expect(Storage).toHaveBeenCalledWith({ apiEndpoint: sample.config.apiEndpoint });
76
+ });
77
+ test('Creates bucket access instance', () => {
78
+ const mockBucket = {};
79
+ const mockStorage = {
80
+ bucket: vi.fn().mockReturnValue(mockBucket),
81
+ };
82
+ vi.mocked(Storage).mockReturnValue(mockStorage);
83
+ const driver = new DriverGCS({
84
+ bucket: sample.config.bucket,
85
+ });
86
+ expect(mockStorage.bucket).toHaveBeenCalledWith(sample.config.bucket);
87
+ expect(driver['bucket']).toBe(mockBucket);
88
+ });
89
+ });
90
+ describe('#fullPath', () => {
91
+ beforeEach(() => {
92
+ driver = new DriverGCS({ bucket: sample.config.bucket });
93
+ driver['root'] = sample.config.root;
94
+ vi.mocked(join).mockReturnValue(sample.path.src);
95
+ vi.mocked(normalizePath).mockReturnValue(sample.path.inputFull);
96
+ });
97
+ test('Returns normalized joined path', () => {
98
+ const result = driver['fullPath'](sample.path.input);
99
+ expect(join).toHaveBeenCalledWith(sample.config.root, sample.path.input);
100
+ expect(normalizePath).toHaveBeenCalledWith(sample.path.src);
101
+ expect(result).toBe(sample.path.inputFull);
102
+ });
103
+ });
104
+ describe('#file', () => {
105
+ let mockFile;
106
+ beforeEach(() => {
107
+ mockFile = {};
108
+ driver['bucket'] = {
109
+ file: vi.fn().mockReturnValue(mockFile),
110
+ };
111
+ });
112
+ test('Uses fullPath to inject root', () => {
113
+ driver['file'](sample.path.input);
114
+ expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
115
+ });
116
+ test('Returns file instance', () => {
117
+ const file = driver['file']('/path/to/file');
118
+ expect(file).toBe(mockFile);
119
+ });
120
+ });
121
+ describe('#read', () => {
122
+ let mockFile;
123
+ beforeEach(() => {
124
+ mockFile = {
125
+ createReadStream: vi.fn().mockReturnValue(sample.stream),
126
+ };
127
+ driver['file'] = vi.fn().mockReturnValue(mockFile);
128
+ });
129
+ test('Gets file reference', async () => {
130
+ await driver.read(sample.path.input);
131
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.inputFull);
132
+ });
133
+ test('Returns stream from createReadStream', async () => {
134
+ const stream = await driver.read(sample.path.input);
135
+ expect(stream).toBe(sample.stream);
136
+ expect(mockFile.createReadStream).toHaveBeenCalledOnce();
137
+ expect(mockFile.createReadStream).toHaveBeenCalledWith(undefined);
138
+ });
139
+ test('Passes optional range to createReadStream', async () => {
140
+ await driver.read('/path/to/file', { start: sample.range.start });
141
+ expect(mockFile.createReadStream).toHaveBeenCalledWith({ start: sample.range.start });
142
+ await driver.read('/path/to/file', sample.range);
143
+ expect(mockFile.createReadStream).toHaveBeenCalledWith(sample.range);
144
+ await driver.read('/path/to/file', { end: sample.range.end });
145
+ expect(mockFile.createReadStream).toHaveBeenCalledWith({ end: sample.range.end });
146
+ });
147
+ });
148
+ describe('#write', () => {
149
+ let mockWriteStream;
150
+ let mockCreateWriteStream;
151
+ let mockSave;
152
+ let mockFile;
153
+ beforeEach(() => {
154
+ mockWriteStream = new PassThrough();
155
+ mockCreateWriteStream = vi.fn().mockReturnValue(mockWriteStream);
156
+ mockSave = vi.fn();
157
+ mockFile = {
158
+ createWriteStream: mockCreateWriteStream,
159
+ save: mockSave,
160
+ };
161
+ driver['file'] = vi.fn().mockReturnValue(mockFile);
162
+ });
163
+ test('Gets file reference for filepath', async () => {
164
+ await driver.write(sample.path.input, sample.stream);
165
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.inputFull);
166
+ });
167
+ test('Pipes read stream to write stream in pipeline when stream is passed', async () => {
168
+ await driver.write(sample.path.inputFull, sample.stream);
169
+ expect(mockCreateWriteStream).toHaveBeenCalledWith({ resumable: false });
170
+ expect(pipeline).toHaveBeenCalledWith(sample.stream, mockWriteStream);
171
+ });
172
+ });
173
+ describe('#delete', () => {
174
+ let mockFile;
175
+ beforeEach(() => {
176
+ mockFile = {
177
+ delete: vi.fn(),
178
+ };
179
+ driver['file'] = vi.fn().mockReturnValue(mockFile);
180
+ });
181
+ test('Gets file reference', async () => {
182
+ await driver.delete(sample.path.input);
183
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.inputFull);
184
+ });
185
+ test('Calls delete on file', async () => {
186
+ await driver.delete(sample.path.input);
187
+ expect(mockFile.delete).toHaveBeenCalledOnce();
188
+ expect(mockFile.delete).toHaveBeenCalledWith();
189
+ });
190
+ });
191
+ describe('#stat', () => {
192
+ let mockFile;
193
+ beforeEach(() => {
194
+ mockFile = {
195
+ getMetadata: vi.fn().mockResolvedValue([{ size: sample.file.size, updated: sample.file.modified }]),
196
+ };
197
+ driver['file'] = vi.fn().mockReturnValue(mockFile);
198
+ });
199
+ test('Gets file reference', async () => {
200
+ await driver.stat(sample.path.input);
201
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.inputFull);
202
+ });
203
+ test('Calls getMetadata on file', async () => {
204
+ await driver.stat(sample.path.input);
205
+ expect(mockFile.getMetadata).toHaveBeenCalledOnce();
206
+ expect(mockFile.getMetadata).toHaveBeenCalledWith();
207
+ });
208
+ test('Returns size/updated as size/modified from metadata response', async () => {
209
+ const result = await driver.stat(sample.path.input);
210
+ expect(result).toStrictEqual({
211
+ size: sample.file.size,
212
+ modified: sample.file.modified,
213
+ });
214
+ });
215
+ });
216
+ describe('#exists', () => {
217
+ let mockFile;
218
+ beforeEach(() => {
219
+ mockFile = {
220
+ exists: vi.fn().mockResolvedValue([true]),
221
+ };
222
+ driver['file'] = vi.fn().mockReturnValue(mockFile);
223
+ });
224
+ test('Gets file reference', async () => {
225
+ driver.exists(sample.path.input);
226
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.inputFull);
227
+ });
228
+ test('Calls exists on file', async () => {
229
+ driver.exists(sample.path.input);
230
+ expect(mockFile.exists).toHaveBeenCalledOnce();
231
+ expect(mockFile.exists).toHaveBeenCalledWith();
232
+ });
233
+ test('Returns boolean from response array', async () => {
234
+ const result = await driver.exists(sample.path.input);
235
+ expect(result).toBe(true);
236
+ });
237
+ });
238
+ describe('#move', () => {
239
+ let mockFileSrc;
240
+ let mockFileDest;
241
+ beforeEach(() => {
242
+ mockFileSrc = {
243
+ move: vi.fn(),
244
+ };
245
+ mockFileDest = {};
246
+ driver['file'] = vi.fn().mockImplementation((path) => {
247
+ if (path === sample.path.srcFull)
248
+ return mockFileSrc;
249
+ if (path === sample.path.destFull)
250
+ return mockFileDest;
251
+ return null;
252
+ });
253
+ });
254
+ test('Gets file references', async () => {
255
+ await driver.move(sample.path.src, sample.path.dest);
256
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.srcFull);
257
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.destFull);
258
+ });
259
+ test('Passes dest file ref to move function', async () => {
260
+ await driver.move(sample.path.src, sample.path.dest);
261
+ expect(mockFileSrc.move).toHaveBeenCalledWith(mockFileDest);
262
+ });
263
+ });
264
+ describe('#copy', () => {
265
+ let mockFileSrc;
266
+ let mockFileDest;
267
+ beforeEach(() => {
268
+ mockFileSrc = {
269
+ copy: vi.fn(),
270
+ };
271
+ mockFileDest = {};
272
+ driver['file'] = vi.fn().mockImplementation((path) => {
273
+ if (path === sample.path.srcFull)
274
+ return mockFileSrc;
275
+ if (path === sample.path.destFull)
276
+ return mockFileDest;
277
+ return null;
278
+ });
279
+ });
280
+ test('Gets file references', async () => {
281
+ await driver.copy(sample.path.src, sample.path.dest);
282
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.srcFull);
283
+ expect(driver['file']).toHaveBeenCalledWith(sample.path.destFull);
284
+ });
285
+ test('Passes dest file ref to copy function', async () => {
286
+ await driver.copy(sample.path.src, sample.path.dest);
287
+ expect(mockFileSrc.copy).toHaveBeenCalledWith(mockFileDest);
288
+ });
289
+ });
290
+ describe('#list', () => {
291
+ let mockFiles;
292
+ beforeEach(() => {
293
+ mockFiles = randFilePath({ length: randNumber({ min: 1, max: 10 }) });
294
+ driver['bucket'] = {
295
+ getFiles: vi.fn(),
296
+ };
297
+ mockFiles.forEach((file, index) => {
298
+ vi.mocked(driver['bucket'].getFiles).mockResolvedValueOnce([
299
+ [{ name: file }],
300
+ index === mockFiles.length - 1 ? undefined : {},
301
+ ]);
302
+ });
303
+ });
304
+ test('Calls getFiles with correct options', async () => {
305
+ await driver.list().next();
306
+ expect(driver['bucket'].getFiles).toHaveBeenCalledWith({
307
+ prefix: '',
308
+ autoPaginate: false,
309
+ maxResults: 500,
310
+ });
311
+ });
312
+ test('Gets full path of optional prefix', async () => {
313
+ await driver.list(sample.path.input).next();
314
+ expect(driver['bucket'].getFiles).toHaveBeenCalledWith({
315
+ prefix: sample.path.inputFull,
316
+ autoPaginate: false,
317
+ maxResults: 500,
318
+ });
319
+ });
320
+ test('Yields all paginated files', async () => {
321
+ const output = [];
322
+ for await (const filepath of driver.list()) {
323
+ output.push(filepath);
324
+ }
325
+ expect(output).toStrictEqual(mockFiles);
326
+ });
327
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@directus/storage-driver-gcs",
3
+ "version": "9.21.2",
4
+ "type": "module",
5
+ "description": "GCS file storage abstraction for `@directus/storage`",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/directus/directus.git",
9
+ "directory": "packages/storage-driver-gcs"
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
+ "@google-cloud/storage": "6.7.0",
28
+ "@directus/storage": "9.21.2",
29
+ "@directus/utils": "9.21.2"
30
+ },
31
+ "devDependencies": {
32
+ "@directus/tsconfig": "0.0.6",
33
+ "@ngneat/falso": "6.3.0",
34
+ "@vitest/coverage-c8": "0.25.3",
35
+ "typescript": "4.9.3",
36
+ "vitest": "0.25.3"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc --build",
40
+ "dev": "tsc --watch",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "test:coverage": "vitest run --coverage"
44
+ }
45
+ }
package/readme.md ADDED
@@ -0,0 +1,3 @@
1
+ # `@directus/storage-driver-gcs`
2
+
3
+ GCS file storage driver for `@directus/storage`