Skip to content

Commit fa75e7a

Browse files
committed
Added support for copyObject
1 parent 7fa38dd commit fa75e7a

4 files changed

Lines changed: 245 additions & 2 deletions

File tree

mod.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
export * from "./src/bucket.ts";
22
export type {
33
GetObjectOptions,
4+
GetObjectResponse,
45
PutObjectOptions,
56
PutObjectResponse,
7+
CopyObjectOptions,
8+
CopyObjectResponse,
9+
DeleteObjectOptions,
10+
DeleteObjectResponse,
611
} from "./src/types.ts";

src/bucket.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
LockMode,
1111
ReplicationStatus,
1212
StorageClass,
13+
CopyObjectOptions,
1314
} from "./types.ts";
1415
import { S3Error } from "./error.ts";
1516

@@ -209,6 +210,7 @@ export class S3Bucket {
209210
? "ON"
210211
: "OFF";
211212
}
213+
212214
const resp = await this._doRequest(
213215
key,
214216
{},
@@ -228,6 +230,100 @@ export class S3Bucket {
228230
};
229231
}
230232

233+
async copyObject(
234+
source: string,
235+
destination: string,
236+
options?: CopyObjectOptions,
237+
): Promise<PutObjectResponse> {
238+
const headers: Params = {};
239+
headers["x-amz-copy-source"] = new URL(encodeURIS3(source), this.#host)
240+
.toString();
241+
if (options?.acl) headers["x-amz-acl"] = options.acl;
242+
if (options?.cacheControl) headers["Cache-Control"] = options.cacheControl;
243+
if (options?.contentDisposition) {
244+
headers["Content-Disposition"] = options.contentDisposition;
245+
}
246+
if (options?.contentEncoding) {
247+
headers["Content-Encoding"] = options.contentEncoding;
248+
}
249+
if (options?.contentLanguage) {
250+
headers["Content-Language"] = options.contentLanguage;
251+
}
252+
if (options?.contentType) headers["Content-Type"] = options.contentType;
253+
if (options?.copyOnlyIfMatch) {
254+
headers["x-amz-copy-source-if-match"] = options.copyOnlyIfMatch;
255+
}
256+
if (options?.copyOnlyIfNoneMatch) {
257+
headers["x-amz-copy-source-if-none-match"] = options.copyOnlyIfNoneMatch;
258+
}
259+
if (options?.copyOnlyIfModifiedSince) {
260+
headers["x-amz-copy-source-if-modified-since"] = options
261+
.copyOnlyIfModifiedSince
262+
.toISOString();
263+
}
264+
if (options?.copyOnlyIfUnmodifiedSince) {
265+
headers["x-amz-copy-source-if-unmodified-since"] = options
266+
.copyOnlyIfUnmodifiedSince
267+
.toISOString();
268+
}
269+
if (options?.grantFullControl) {
270+
headers["x-amz-grant-full-control"] = options.grantFullControl;
271+
}
272+
if (options?.grantRead) headers["x-amz-grant-read"] = options.grantRead;
273+
if (options?.grantReadAcp) {
274+
headers["x-amz-grant-read-acp"] = options.grantReadAcp;
275+
}
276+
if (options?.grantWriteAcp) {
277+
headers["x-amz-grant-write-acp"] = options.grantWriteAcp;
278+
}
279+
if (options?.storageClass) {
280+
headers["x-amz-storage-class"] = options.storageClass;
281+
}
282+
283+
if (options?.websiteRedirectLocation) {
284+
headers["x-amz-website-redirect-location"] =
285+
options.websiteRedirectLocation;
286+
}
287+
if (options?.tags) {
288+
const p = new URLSearchParams(options.tags);
289+
headers["x-amz-tagging"] = p.toString();
290+
}
291+
if (options?.lockMode) headers["x-amz-object-lock-mode"] = options.lockMode;
292+
if (options?.lockRetainUntil) {
293+
headers["x-amz-object-lock-retain-until-date"] = options.lockRetainUntil
294+
.toString();
295+
}
296+
if (options?.legalHold) {
297+
headers["x-amz-object-lock-legal-hold"] = options.legalHold
298+
? "ON"
299+
: "OFF";
300+
}
301+
if (options?.metadataDirective) {
302+
headers["x-amz-metadata-directive"] = options.metadataDirective;
303+
}
304+
if (options?.taggingDirective) {
305+
headers["x-amz-tagging-directive"] = options.taggingDirective;
306+
}
307+
308+
const resp = await this._doRequest(
309+
destination,
310+
{},
311+
"PUT",
312+
headers,
313+
);
314+
if (resp.status !== 200) {
315+
throw new S3Error(
316+
`Failed to copy object: ${resp.status} ${resp.statusText}`,
317+
await resp.text(),
318+
);
319+
}
320+
await resp.arrayBuffer();
321+
return {
322+
etag: JSON.parse(resp.headers.get("etag")!),
323+
versionId: resp.headers.get("x-amz-version-id") ?? undefined,
324+
};
325+
}
326+
231327
async deleteObject(
232328
key: string,
233329
options?: DeleteObjectOptions,

src/bucket_test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { assert, assertEquals, assertThrowsAsync } from "../test_deps.ts";
1+
import { assert, assertEquals } from "../test_deps.ts";
22
import { S3Bucket } from "./bucket.ts";
3-
import { S3Error } from "./error.ts";
43

54
const bucket = new S3Bucket({
65
accessKeyID: Deno.env.get("AWS_ACCESS_KEY_ID")!,
@@ -90,3 +89,22 @@ Deno.test({
9089
assertEquals(await bucket.getObject("test"), undefined);
9190
},
9291
});
92+
93+
Deno.test({
94+
name: "copy object",
95+
async fn() {
96+
await bucket.putObject(
97+
"test3",
98+
encoder.encode("Test1"),
99+
);
100+
await bucket.copyObject("test3", "test4", {
101+
contentType: "text/plain",
102+
metadataDirective: "REPLACE",
103+
}).catch((e) => console.log(e.response));
104+
const res = await bucket.getObject("test4");
105+
assert(res);
106+
assertEquals(res.contentType, "text/plain");
107+
assertEquals(res.contentLength, 5);
108+
assertEquals(res.contentType, "text/plain");
109+
},
110+
});

src/types.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type StorageClass =
1414
| "GLACIER"
1515
| "DEEP_ARCHIVE";
1616

17+
export type CopyDirective = "COPY" | "REPLACE";
18+
1719
export interface GetObjectOptions {
1820
/**
1921
* Return the object only if its entity tag (ETag) is the same as the one
@@ -257,6 +259,128 @@ export interface PutObjectResponse {
257259
versionId?: string;
258260
}
259261

262+
export interface CopyObjectOptions {
263+
acl?:
264+
| "private"
265+
| "public-read"
266+
| "public-read-write"
267+
| "authenticated-read"
268+
| "aws-exec-read"
269+
| "bucket-owner-read"
270+
| "bucket-owner-full-control";
271+
272+
/** Can be used to specify caching behavior along the request/reply chain. */
273+
cacheControl?: string;
274+
275+
/** Specifies presentational information for the object. */
276+
contentDisposition?: string;
277+
278+
/**
279+
* Specifies what content encodings have been applied to the object
280+
* and thus what decoding mechanisms must be applied to obtain the
281+
* media-type referenced by the Content-Type field.
282+
*/
283+
contentEncoding?: string;
284+
285+
/** The language the content is in. */
286+
contentLanguage?: string;
287+
288+
/** A standard MIME type describing the format of the object data. */
289+
contentType?: string;
290+
291+
/** The date and time at which the object is no longer cacheable. */
292+
expires?: Date;
293+
294+
/**
295+
* Copy the object only if its entity tag (ETag) is the same as the one
296+
* specified, otherwise return a 412 (precondition failed).
297+
*/
298+
copyOnlyIfMatch?: string;
299+
300+
/**
301+
* Copy the object only if its entity tag (ETag) is different from the one
302+
* specified, otherwise return a 304 (not modified).
303+
*/
304+
copyOnlyIfNoneMatch?: string;
305+
306+
/**
307+
* Copy the object only if it has been modified since the specified time,
308+
* otherwise return a 304 (not modified).
309+
*/
310+
copyOnlyIfModifiedSince?: Date;
311+
312+
/**
313+
* Copy the object only if it has not been modified since the specified
314+
* time, otherwise return a 412 (precondition failed).
315+
*/
316+
copyOnlyIfUnmodifiedSince?: Date;
317+
318+
// TOOD: better structured data
319+
/** Gives the grantee READ, READ_ACP, and WRITE_ACP permissions on the object. */
320+
grantFullControl?: string;
321+
322+
// TOOD: better structured data
323+
/** Allows grantee to read the object data and its metadata. */
324+
grantRead?: string;
325+
326+
// TOOD: better structured data
327+
/** Allows grantee to write the ACL for the applicable object. */
328+
grantReadAcp?: string;
329+
330+
// TOOD: better structured data
331+
/** Allows grantee to write the ACL for the applicable object. */
332+
grantWriteAcp?: string;
333+
334+
/**
335+
* Specifies whether the metadata is copied from the source object or replaced
336+
* with metadata provided in the request.
337+
*/
338+
metadataDirective?: CopyDirective;
339+
340+
/** Specifies whether a legal hold will be applied to this object. */
341+
legalHold?: boolean;
342+
343+
/** The Object Lock mode that you want to apply to this object. */
344+
lockMode?: LockMode;
345+
346+
/** The date and time when you want this object's Object Lock to expire. */
347+
lockRetainUntil?: Date;
348+
349+
/**
350+
* If you don't specify, S3 Standard is the default storage class.
351+
* Amazon S3 supports other storage classes.
352+
*/
353+
storageClass?: StorageClass;
354+
355+
tags?: { [key: string]: string };
356+
357+
/**
358+
* Specifies whether the object tag-set are copied from the source object or
359+
* replaced with tag-set provided in the request.
360+
*/
361+
taggingDirective?: CopyDirective;
362+
363+
/**
364+
* If the bucket is configured as a website, redirects requests for this
365+
* object to another object in the same bucket or to an external URL.
366+
* Amazon S3 stores the value of this header in the object metadata.
367+
*/
368+
websiteRedirectLocation?: string;
369+
}
370+
371+
export interface CopyObjectResponse {
372+
/**
373+
* An ETag is an opaque identifier assigned by a web server to a
374+
* specific version of a resource found at a URL.
375+
*/
376+
etag: string;
377+
378+
/**
379+
* Version of the object.
380+
*/
381+
versionId?: string;
382+
}
383+
260384
export interface DeleteObjectOptions {
261385
versionId?: string;
262386
}

0 commit comments

Comments
 (0)