/**
* S3 is NOT a DB
* Simple interface to using Amazon S3 as a database.
*
* Copyright 2023, Marc S. Brooks (https://mbrooks.info)
* Licensed under the MIT license:
* http://www.opensource.org/licenses/mit-license.php
*/
'use strict';
const {
BucketLocationConstraint
} = require('@aws-sdk/client-s3/dist-cjs/models/models_0');
const {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client
} = require('@aws-sdk/client-s3');
const {
throwError
} = require('./Errors');
/**
* Provides S3 client methods.
*/
class Client {
#handle;
#bucket;
#region;
/**
* @param {Object} bucket
* S3 Bucket name.
*
* @param {Object} region
* S3 Region name.
*
* @example
* const client = new Client('s3-is-not-a-db', 'us-east-1');
*/
constructor(bucket, region) {
this.#handle = null;
this.#init(bucket, region);
}
/**
* Set defaults.
*/
#init(bucket, region) {
this.bucket = bucket;
this.region = region;
}
/**
* Getters.
*/
get handle() {
return new S3Client({region: this.region});
}
get bucket() {
return this.#bucket;
}
get region() {
return this.#region;
}
// Setters.
set bucket(value) {
if (isValidBucket(value)) {
this.#bucket = value;
}
}
set region(value) {
if (isValidRegion(value)) {
this.#region = value;
}
}
/**
* List objects in a S3 bucket.
*
* @param {String} prefix
* Object Prefix.
*
* @return {Promise<Array|Error>}
*
* @example
* const objects = await client.list('/path/to/objects');
* // ['foo.ext', 'bar.ext', 'biz.ext', 'baz.ext']
*/
async list(prefix) {
if (prefix) {
const command = new ListObjectsV2Command({
Bucket: this.bucket,
Prefix: prefix,
});
try {
let isTruncated = true;
const contents = [];
while (isTruncated) {
const {Contents, IsTruncated, NextContinuationToken}
= await this.handle.send(command);
contents.push(...Contents.map(content => content.Key));
isTruncated = IsTruncated;
command.input.ContinuationToken = NextContinuationToken;
}
return contents;
} catch (err) /* istanbul ignore next */ {
console.warn(err.message);
throw err;
}
}
throwError('INVALID_BUCKET_PREFIX', prefix);
}
/**
* Delete object from S3 bucket.
*
* @param {String} value
* Object name/prefix.
*
* @return {Promise<Object|Error>}
*
* @example
* await client.delete('/path/to/keyName');
*/
async delete(value) {
if (isValidPrefix(value)) {
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: value
});
try {
return await this.handle.send(command);
} catch (err) /* istanbul ignore next */ {
console.warn(err.message);
throw err;
}
}
throwError('INVALID_BUCKET_PREFIX', value);
}
/**
* Fetch object from S3 bucket.
*
* @param {String} value
* Object Prefix.
*
* @return {Promise<Object|Error>}
*
* @example
* const data = await client.fetch('/path/to/keyName');
*/
async fetch(value) {
if (isValidPrefix(value)) {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: value
});
try {
const response = await this.handle.send(command);
return await response.Body;
} catch (err) /* istanbul ignore next */ {
if (err.name !== 'NoSuchKey') {
console.warn(err.message);
throw err;
}
return false;
}
}
throwError('INVALID_BUCKET_PREFIX', value);
}
/**
* Write/overwrite object in S3 bucket.
*
* @param {String} value
* Object Prefix.
*
* @param {String|Buffer} data
* Object data.
*
* @param {Object<contentType|metaData>} options
* Request options.
*
* @return {Promise<Object|Error>}
*
* @example
* await client.write('/path/to/keyName', 'foo', 'text/plain');
*/
async write(value, data, options) {
if (isValidPrefix(value)) {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: value,
Body: data,
ContentType: options?.contentType,
ContentLength: (data) ? Buffer.from(data).size : 0,
Metadata: options?.metaData,
});
try {
return await this.handle.send(command);
} catch (err) /* istanbul ignore next */ {
console.warn(err.message);
throw err;
}
}
throwError('INVALID_BUCKET_PREFIX', value);
}
/**
* Rename object in S3 bucket.
*
* @param {String} oldValue
* Old object Prefix as string.
*
* @param {String} newValue
* New object Prefix as string.
*
* @return {Promise<Object|Error>}
*
* @example
* await client.rename('/path/to/keyName1', '/path/to/keyName2');
*/
async rename(oldValue, newValue) {
if (!isValidPrefix(oldValue) || !(await this.exists(oldValue))) {
throwError('INVALID_BUCKET_PREFIX', oldValue);
}
if (!isValidPrefix(newValue) || await this.exists(newValue)) {
throwError('INVALID_OBJECT_TARGET', newValue);
}
const data = await this.fetch(oldValue);
const json = JSON.stringify(data);
await this.delete(oldValue);
await this.write(newValue, json, 'application/json');
}
/**
* Check object exists in S3 bucket.
*
* @param {String} value
* Object Prefix as string.
*
* @return {Promise<Object|Boolean|Error>}
*
* @example
* const exists = await client.exists('/path/to/keyName');
*/
async exists(value) {
if (isValidPrefix(value)) {
const command = new HeadObjectCommand({
Bucket: this.bucket,
Key: value
});
try {
return await this.handle.send(command);
} catch (err) /* istanbul ignore next */ {
if (err.name !== 'NotFound') {
console.warn(err.message);
throw err;
}
return false;
}
}
throwError('INVALID_BUCKET_PREFIX', value);
}
}
/**
* Verify Bucket format.
*
* @param {String} value
* Value as string.
*
* @return {Boolean}
*/
function isValidBucket(value) {
return value.length > 0 && /^[a-z0-9-_.]{3,63}$/.test(value);
}
/**
* Verify Bucket Prefix (path) format.
*
* @param {String} value
* Value as string.
*
* @return {Boolean}
*/
function isValidPrefix(value) {
return value.length > 0 && /^[a-z0-9-_!.*()'\/]+$/i.test(value);
}
/**
* Verify Bucket Region name format.
*
* @param {String} value
* Value as string.
*
* @return {Boolean}
*/
function isValidRegion(value) {
const list = Object.values(BucketLocationConstraint);
return value === 'us-east-1' || list.includes(value);
}
module.exports = Client;