Skip to content

Commit 666efa3

Browse files
committed
feat: support cross-bucket copy and move operations
1 parent 6bf9b31 commit 666efa3

8 files changed

Lines changed: 204 additions & 36 deletions

File tree

drivers/gcs/driver.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { DriveFile } from '../../src/driver_file.js'
2323
import { DriveDirectory } from '../../src/drive_directory.js'
2424
import type {
2525
WriteOptions,
26+
CopyMoveOptions,
2627
ObjectMetaData,
2728
DriverContract,
2829
SignedURLOptions,
@@ -381,58 +382,75 @@ export class GCSDriver implements DriverContract {
381382
/**
382383
* Copies the source file to the destination. Both paths must
383384
* be within the root location.
385+
*
386+
* Use the "bucket" option to copy the file to a different bucket.
384387
*/
385-
async copy(source: string, destination: string, options?: WriteOptions): Promise<void> {
388+
async copy(source: string, destination: string, options?: CopyMoveOptions): Promise<void> {
389+
const { bucket: destinationBucket, ...writeOptions } = options || {}
390+
const targetBucket = destinationBucket || this.options.bucket
391+
386392
debug(
387393
'copying file from %s:%s to %s:%s',
388394
this.options.bucket,
389395
source,
390-
this.options.bucket,
396+
targetBucket,
391397
destination
392398
)
393-
const bucket = this.#storage.bucket(this.options.bucket)
394-
options = options || {}
399+
400+
const sourceBucket = this.#storage.bucket(this.options.bucket)
395401

396402
/**
397403
* Copy visibility from the source file to the
398404
* desintation when no inline visibility is
399405
* defined and not using usingUniformAcl
400406
*/
401-
if (!options.visibility && !this.#usingUniformAcl) {
402-
const [isFilePublic] = await bucket.file(source).isPublic()
403-
options.visibility = isFilePublic ? 'public' : 'private'
407+
if (!writeOptions.visibility && !this.#usingUniformAcl) {
408+
const [isFilePublic] = await sourceBucket.file(source).isPublic()
409+
writeOptions.visibility = isFilePublic ? 'public' : 'private'
404410
}
405411

406-
await bucket.file(source).copy(destination, this.#getSaveOptions(options))
412+
const target = destinationBucket
413+
? this.#storage.bucket(destinationBucket).file(destination)
414+
: destination
415+
416+
await sourceBucket.file(source).copy(target, this.#getSaveOptions(writeOptions))
407417
}
408418

409419
/**
410420
* Moves the source file to the destination. Both paths must
411421
* be within the root location.
422+
*
423+
* Use the "bucket" option to move the file to a different bucket.
412424
*/
413-
async move(source: string, destination: string, options?: WriteOptions): Promise<void> {
425+
async move(source: string, destination: string, options?: CopyMoveOptions): Promise<void> {
426+
const { bucket: destinationBucket, ...writeOptions } = options || {}
427+
const targetBucket = destinationBucket || this.options.bucket
428+
414429
debug(
415430
'moving file from %s:%s to %s:%s',
416431
this.options.bucket,
417432
source,
418-
this.options.bucket,
433+
targetBucket,
419434
destination
420435
)
421436

422-
const bucket = this.#storage.bucket(this.options.bucket)
423-
options = options || {}
437+
const sourceBucket = this.#storage.bucket(this.options.bucket)
424438

425439
/**
426440
* Copy visibility from the source file to the
427441
* desintation when no inline visibility is
428442
* defined and not using usingUniformAcl
429443
*/
430-
if (!options.visibility && !this.#usingUniformAcl) {
431-
const [isFilePublic] = await bucket.file(source).isPublic()
432-
options.visibility = isFilePublic ? 'public' : 'private'
444+
if (!writeOptions.visibility && !this.#usingUniformAcl) {
445+
const [isFilePublic] = await sourceBucket.file(source).isPublic()
446+
writeOptions.visibility = isFilePublic ? 'public' : 'private'
433447
}
434448

435-
await bucket.file(source).move(destination, this.#getSaveOptions(options))
449+
const target = destinationBucket
450+
? this.#storage.bucket(destinationBucket).file(destination)
451+
: destination
452+
453+
await sourceBucket.file(source).move(target, this.#getSaveOptions(writeOptions))
436454
}
437455

438456
/**

drivers/s3/driver.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { DriveFile } from '../../src/driver_file.js'
3939
import { DriveDirectory } from '../../src/drive_directory.js'
4040
import type {
4141
WriteOptions,
42+
CopyMoveOptions,
4243
DriverContract,
4344
ObjectMetaData,
4445
ObjectVisibility,
@@ -583,47 +584,54 @@ export class S3Driver implements DriverContract {
583584
/**
584585
* Copies the source file to the destination. Both paths must
585586
* be within the root location.
587+
*
588+
* Use the "bucket" option to copy the file to a different bucket.
586589
*/
587-
async copy(source: string, destination: string, options?: WriteOptions): Promise<void> {
590+
async copy(source: string, destination: string, options?: CopyMoveOptions): Promise<void> {
591+
const { bucket: destinationBucket, ...writeOptions } = options || {}
592+
const targetBucket = destinationBucket || this.options.bucket
593+
588594
debug(
589595
'copying file from %s:%s to %s:%s',
590596
this.options.bucket,
591597
source,
592-
this.options.bucket,
598+
targetBucket,
593599
destination
594600
)
595601

596-
options = options || {}
597-
598602
/**
599603
* Copy visibility from the source file to the
600604
* destination when no inline visibility is
601605
* defined
602606
*/
603-
if (!options.visibility && this.#supportsACL) {
604-
options.visibility = await this.getVisibility(source)
607+
if (!writeOptions.visibility && this.#supportsACL) {
608+
writeOptions.visibility = await this.getVisibility(source)
605609
}
606610

607611
await this.#client.send(
608612
this.createCopyObjectCommand(this.#client, {
609-
...this.#getSaveOptions(destination, options),
613+
...this.#getSaveOptions(destination, writeOptions),
610614
Key: destination,
611615
CopySource: `/${this.options.bucket}/${source}`,
612-
Bucket: this.options.bucket,
616+
Bucket: targetBucket,
613617
})
614618
)
615619
}
616620

617621
/**
618622
* Moves the source file to the destination. Both paths must
619623
* be within the root location.
624+
*
625+
* Use the "bucket" option to move the file to a different bucket.
620626
*/
621-
async move(source: string, destination: string, options?: WriteOptions): Promise<void> {
627+
async move(source: string, destination: string, options?: CopyMoveOptions): Promise<void> {
628+
const targetBucket = options?.bucket || this.options.bucket
629+
622630
debug(
623631
'moving file from %s:%s to %s:%s',
624632
this.options.bucket,
625633
source,
626-
this.options.bucket,
634+
targetBucket,
627635
destination
628636
)
629637

src/disk.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
DriverContract,
2323
ObjectVisibility,
2424
SignedURLOptions,
25+
CopyMoveOptions,
2526
} from './types.js'
2627

2728
/**
@@ -169,13 +170,13 @@ export class Disk {
169170
}
170171

171172
/**
172-
* Copies file from the "source" to the "destination" within the
173-
* same bucket or the root location of local filesystem.
173+
* Copies file from the "source" to the "destination". Use the "bucket"
174+
* option to copy the file to a different bucket.
174175
*
175176
* Use "copyFromFs" method to copy files from local filesystem to
176177
* a cloud provider
177178
*/
178-
async copy(source: string, destination: string, options?: WriteOptions): Promise<void> {
179+
async copy(source: string, destination: string, options?: CopyMoveOptions): Promise<void> {
179180
source = this.#normalizer.normalize(source)
180181
destination = this.#normalizer.normalize(destination)
181182
try {
@@ -193,13 +194,13 @@ export class Disk {
193194
}
194195

195196
/**
196-
* Moves file from the "source" to the "destination" within the
197-
* same bucket or the root location of local filesystem.
197+
* Moves file from the "source" to the "destination". Use the "bucket"
198+
* option to move the file to a different bucket.
198199
*
199200
* Use "moveFromFs" method to move files from local filesystem to
200201
* a cloud provider
201202
*/
202-
async move(source: string, destination: string, options?: WriteOptions): Promise<void> {
203+
async move(source: string, destination: string, options?: CopyMoveOptions): Promise<void> {
203204
source = this.#normalizer.normalize(source)
204205
destination = this.#normalizer.normalize(destination)
205206
try {

src/types.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ export type WriteOptions = {
4242
[key: string]: any
4343
}
4444

45+
/**
46+
* Options accepted by the copy and move operations.
47+
*/
48+
export type CopyMoveOptions = WriteOptions & {
49+
bucket?: string
50+
}
51+
4552
/**
4653
* Options accepted during the creation of a signed URL.
4754
*/
@@ -155,15 +162,19 @@ export interface DriverContract {
155162
* Copy the file from within the disk root location. Both
156163
* the "source" and "destination" will be the key names
157164
* and not absolute paths.
165+
*
166+
* Use the "bucket" option to copy the file to a different bucket.
158167
*/
159-
copy(source: string, destination: string, options?: WriteOptions): Promise<void>
168+
copy(source: string, destination: string, options?: CopyMoveOptions): Promise<void>
160169

161170
/**
162171
* Move the file from within the disk root location. Both
163172
* the "source" and "destination" will be the key names
164173
* and not absolute paths.
174+
*
175+
* Use the "bucket" option to move the file to a different bucket.
165176
*/
166-
move(source: string, destination: string, options?: WriteOptions): Promise<void>
177+
move(source: string, destination: string, options?: CopyMoveOptions): Promise<void>
167178

168179
/**
169180
* Delete the file for the given key. Should not throw

tests/drivers/gcs/copy.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,22 @@ test.group('GCS Driver | copy', (group) => {
109109
const existsResponse = await noUniformedAclBucket.file(source).exists()
110110
assert.isTrue(existsResponse[0])
111111
})
112+
113+
test('copy file with explicit bucket option', async ({ assert }) => {
114+
const source = `${string.random(6)}.txt`
115+
const destination = `${string.random(6)}.txt`
116+
const contents = 'Hello world'
117+
118+
const fdgcs = new GCSDriver({
119+
visibility: 'public',
120+
bucket: GCS_BUCKET,
121+
credentials: GCS_KEY,
122+
usingUniformAcl: true,
123+
})
124+
await fdgcs.put(source, contents)
125+
await fdgcs.copy(source, destination, { bucket: GCS_BUCKET })
126+
127+
assert.equal(await fdgcs.get(destination), contents)
128+
assert.isTrue((await bucket.file(source).exists())[0])
129+
})
112130
})

tests/drivers/gcs/move.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,22 @@ test.group('GCS Driver | move', (group) => {
114114
const existsResponse = await noUniformedAclBucket.file(source).exists()
115115
assert.isFalse(existsResponse[0])
116116
})
117+
118+
test('move file with explicit bucket option', async ({ assert }) => {
119+
const source = `${string.random(10)}.txt`
120+
const destination = `${string.random(10)}.txt`
121+
const contents = 'Hello world'
122+
123+
const fdgcs = new GCSDriver({
124+
visibility: 'public',
125+
bucket: GCS_BUCKET,
126+
credentials: GCS_KEY,
127+
usingUniformAcl: true,
128+
})
129+
await fdgcs.put(source, contents)
130+
await fdgcs.move(source, destination, { bucket: GCS_BUCKET })
131+
132+
assert.equal(await fdgcs.get(destination), contents)
133+
assert.isFalse((await bucket.file(source).exists())[0])
134+
})
117135
})

tests/drivers/s3/copy.spec.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { test } from '@japa/runner'
1111
import string from '@poppinss/utils/string'
12-
import { S3Client } from '@aws-sdk/client-s3'
12+
import { S3Client, type CopyObjectCommandInput } from '@aws-sdk/client-s3'
1313

1414
import { S3Driver } from '../../../drivers/s3/driver.js'
1515
import {
@@ -136,4 +136,51 @@ test.group('S3 Driver | copy', (group) => {
136136

137137
assert.isTrue(await s3fs.exists(source))
138138
}).skip(!SUPPORTS_ACL, 'Service does not support ACL. Hence, we cannot control file visibility')
139+
140+
test('copy file with explicit bucket option', async ({ assert }) => {
141+
const source = `${string.random(6)}.txt`
142+
const destination = `${string.random(6)}.txt`
143+
const contents = 'Hello world'
144+
145+
const s3fs = new S3Driver({
146+
visibility: 'public',
147+
client: client,
148+
bucket: S3_BUCKET,
149+
supportsACL: SUPPORTS_ACL,
150+
})
151+
await s3fs.put(source, contents)
152+
await s3fs.copy(source, destination, { bucket: S3_BUCKET })
153+
154+
assert.equal(await s3fs.get(destination), contents)
155+
assert.isTrue(await s3fs.exists(source))
156+
})
157+
158+
test('copy command receives the correct destination bucket', async ({ assert }) => {
159+
let capturedOptions: CopyObjectCommandInput | undefined
160+
161+
class TestS3Driver extends S3Driver {
162+
protected createCopyObjectCommand(_client: S3Client, options: CopyObjectCommandInput) {
163+
capturedOptions = options
164+
return super.createCopyObjectCommand(_client, options)
165+
}
166+
}
167+
168+
const source = `${string.random(6)}.txt`
169+
const destination = `${string.random(6)}.txt`
170+
const contents = 'Hello world'
171+
172+
const s3fs = new TestS3Driver({
173+
visibility: 'public',
174+
client: client,
175+
bucket: S3_BUCKET,
176+
supportsACL: SUPPORTS_ACL,
177+
})
178+
179+
await s3fs.put(source, contents)
180+
await s3fs.copy(source, destination, { bucket: 'other-bucket' }).catch(() => {})
181+
182+
assert.isDefined(capturedOptions)
183+
assert.equal(capturedOptions!.Bucket, 'other-bucket')
184+
assert.equal(capturedOptions!.CopySource, `/${S3_BUCKET}/${source}`)
185+
})
139186
})

0 commit comments

Comments
 (0)