Skip to content

Commit

Permalink
Version 0.3.0 - adds copyObject(), bumps deno std lib
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed Dec 20, 2022
2 parents bddedfa + e8378cd commit a2e547f
Show file tree
Hide file tree
Showing 6 changed files with 129 additions and 26 deletions.
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# deno-s3-lite-client

A very lightweight S3 client for Deno. Has no dependencies outside of the Deno standard library. MIT licensed.
This is a lightweight S3 client for Deno, designed to offer all the key features you may need, with no dependencies
outside of the Deno standard library.

It is derived from the excellent [MinIO JavaScript Client](https://github.com/minio/minio-js). Note however that only a
tiny subset of that client's functionality has been implemented.
This client is 100% MIT licensed, and is derived from the excellent
[MinIO JavaScript Client](https://github.com/minio/minio-js).

Supported functionality:

Expand All @@ -16,12 +17,15 @@ Supported functionality:
- Check if an object exists: `client.exists("key")`
- Get metadata about an object: `client.statObject("key")`
- Download an object: `client.getObject("key", options)`
- Supports streaming the response
- This just returns a standard HTTP `Response` object, so for large files, you can opt to consume the data as a stream
(use the `.body` property).
- Download a partial object: `client.getPartialObject("key", options)`
- Supports streaming the response
- Like `getObject`, this also supports streaming the response if you want to.
- Upload an object: `client.putObject("key", streamOrData, options)`
- Can upload from a `string`, `Uint8Array`, or `ReadableStream`
- Can split large uploads into multiple parts and uploads parts in parallel
- Can split large uploads into multiple parts and uploads parts in parallel.
- Copy an object: `client.copyObject({ sourceKey: "source", options }, "dest", options)`
- Can copy between different buckets.
- Delete an object: `client.deleteObject("key")`
- Create pre-signed URLs: `client.presignedGetObject("key", options)` or
`client.getPresignedUrl(method, "key", options)`
Expand All @@ -31,7 +35,7 @@ Supported functionality:
List data files from a public data set on Amazon S3:

```typescript
import { S3Client } from "https://deno.land/x/s3_lite_client@0.2.0/mod.ts";
import { S3Client } from "https://deno.land/x/s3_lite_client@0.3.0/mod.ts";

const s3client = new S3Client({
endPoint: "s3.amazonaws.com",
Expand All @@ -50,7 +54,7 @@ for await (const obj of s3client.listObjects({ prefix: "data/concepts/" })) {
Upload a file to a local MinIO server:

```typescript
import { S3Client } from "https://deno.land/x/s3_lite_client@0.2.0/mod.ts";
import { S3Client } from "https://deno.land/x/s3_lite_client@0.3.0/mod.ts";

// Connecting to a local MinIO server:
const s3client = new S3Client({
Expand Down
53 changes: 53 additions & 0 deletions client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export interface UploadedObjectInfo {
versionId: string | null;
}

export interface CopiedObjectInfo extends UploadedObjectInfo {
lastModified: Date;
copySourceVersionId: string | null;
}

/** Details about an object as returned by a "list objects" operation */
export interface S3Object {
type: "Object";
Expand Down Expand Up @@ -756,4 +761,52 @@ export class Client {
etag: sanitizeETag(response.headers.get("ETag") ?? ""),
};
}

/**
* Copy an object into this bucket
*/
public async copyObject(
source: { sourceBucketName?: string; sourceKey: string; sourceVersionId?: string },
objectName: string,
options?: { bucketName?: string },
): Promise<CopiedObjectInfo> {
const bucketName = this.getBucketName(options);
const sourceBucketName = source.sourceBucketName ?? bucketName;
if (!isValidObjectName(objectName)) {
throw new errors.InvalidObjectNameError(`Invalid object name: ${objectName}`);
}

// The "x-amz-copy-source" header is like "bucket/objectkey" with an optional version ID.
// e.g. "awsexamplebucket/reports/january.pdf?versionId=QUpfdndhfd8438MNFDN93jdnJFkdmqnh893"
let xAmzCopySource = `${sourceBucketName}/${source.sourceKey}`;
if (source.sourceVersionId) xAmzCopySource += `?versionId=${source.sourceVersionId}`;

const response = await this.makeRequest({
method: "PUT",
bucketName,
objectName,
headers: new Headers({ "x-amz-copy-source": xAmzCopySource }),
returnBody: true,
});

const responseText = await response.text();
// Parse the response XML.
// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_ResponseSyntax
const root = parseXML(responseText).root;
if (!root || root.name !== "CopyObjectResult") {
throw new Error(`Unexpected response: ${responseText}`);
}
const etagString = root.children.find((c) => c.name === "ETag")?.content ?? "";
const lastModifiedString = root.children.find((c) => c.name === "LastModified")?.content;
if (lastModifiedString === undefined) {
throw new Error("Unable to find <LastModified>...</LastModified> from the server.");
}

return {
copySourceVersionId: response.headers.get("x-amz-copy-source-version-id") || null,
etag: sanitizeETag(etagString),
lastModified: new Date(lastModifiedString),
versionId: response.headers.get("x-amz-version-id") || null,
};
}
}
3 changes: 2 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"options": {
"lineWidth": 120
}
}
},
"lock": false
}
7 changes: 6 additions & 1 deletion deps-tests.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { assert, assertEquals, assertRejects } from "https://deno.land/[email protected]/testing/asserts.ts";
export {
assert,
assertEquals,
assertInstanceOf,
assertRejects,
} from "https://deno.land/[email protected]/testing/asserts.ts";
4 changes: 2 additions & 2 deletions deps.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { readableStreamFromIterable } from "https://deno.land/std@0.125.0/streams/conversion.ts";
export { Buffer } from "https://deno.land/std@0.125.0/io/buffer.ts";
export { readableStreamFromIterable } from "https://deno.land/std@0.170.0/streams/readable_stream_from_iterable.ts";
export { Buffer } from "https://deno.land/std@0.170.0/io/buffer.ts";
68 changes: 54 additions & 14 deletions integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See the README for instructions.
*/
import { readableStreamFromIterable } from "./deps.ts";
import { assert, assertEquals, assertRejects } from "./deps-tests.ts";
import { assert, assertEquals, assertInstanceOf, assertRejects } from "./deps-tests.ts";
import { S3Client, S3Errors } from "./mod.ts";

const config = {
Expand Down Expand Up @@ -46,20 +46,18 @@ Deno.test({
name: "error parsing",
fn: async () => {
const unauthorizedClient = new S3Client({ ...config, secretKey: "invalid key" });
await assertRejects(
const err = await assertRejects(
() => unauthorizedClient.putObject("test.txt", "This is the contents of the file."),
(err: Error) => {
assert(err instanceof S3Errors.ServerError);
assertEquals(err.statusCode, 403);
assertEquals(err.code, "SignatureDoesNotMatch");
assertEquals(
err.message,
"The request signature we calculated does not match the signature you provided. Check your key and signing method.",
);
assertEquals(err.bucketName, config.bucket);
assertEquals(err.region, config.region);
},
);
assertInstanceOf(err, S3Errors.ServerError);
assertEquals(err.statusCode, 403);
assertEquals(err.code, "SignatureDoesNotMatch");
assertEquals(
err.message,
"The request signature we calculated does not match the signature you provided. Check your key and signing method.",
);
assertEquals(err.bucketName, config.bucket);
assertEquals(err.region, config.region);
},
});

Expand Down Expand Up @@ -164,7 +162,7 @@ Deno.test({
const stat = await client.statObject(key);
assertEquals(stat.type, "Object");
assertEquals(stat.key, key);
assert(stat.lastModified instanceof Date);
assertInstanceOf(stat.lastModified, Date);
assertEquals(stat.lastModified.getFullYear(), new Date().getFullYear()); // This may fail at exactly midnight on New Year's, no big deal
assertEquals(stat.size, new TextEncoder().encode(contents).length); // Size in bytes is different from the length of the string
assertEquals(stat.versionId, null);
Expand Down Expand Up @@ -317,3 +315,45 @@ Deno.test({
assertEquals(results[4].key, `${prefix}x-file.txt`);
},
});

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// copyObject()

Deno.test({
name: "copyObject() can copy a file",
fn: async () => {
const contents = "This is the contents of the copy test file. 👻"; // Throw in an Emoji to ensure Unicode round-trip is working.
const sourceKey = "test-copy-source.txt";
const destKey = "test-copy-dest.txt";

// Create the source file:
const uploadResult = await client.putObject(sourceKey, contents);
// Make sure the destination doesn't yet exist:
await client.deleteObject(destKey);
assertEquals(await client.exists(destKey), false);

const response = await client.copyObject({ sourceKey }, destKey);
assertEquals(uploadResult.etag, response.etag);
assertEquals(uploadResult.versionId, response.copySourceVersionId);
assertInstanceOf(response.lastModified, Date);

// Download the file to confirm that the copy worked.
const downloadResult = await client.getObject(destKey);
assertEquals(await downloadResult.text(), contents);
},
});

Deno.test({
name: "copyObject() gives an appropriate error if the source file doesn't exist.",
fn: async () => {
const sourceKey = "non-existent-source";
const err = await assertRejects(
() => client.copyObject({ sourceKey }, "any-dest.txt"),
);
assertInstanceOf(err, S3Errors.ServerError);
assertEquals(err.code, "NoSuchKey");
assertEquals(err.statusCode, 404);
assertEquals(err.key, sourceKey);
assertEquals(err.message, "The specified key does not exist.");
},
});

0 comments on commit a2e547f

Please sign in to comment.