Skip to content

Commit de9feb0

Browse files
committed
feat: provide minio provider as alternative
1 parent 8811f7c commit de9feb0

File tree

9 files changed

+702
-8
lines changed

9 files changed

+702
-8
lines changed

package-lock.json

Lines changed: 448 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
"dependencies": {
6363
"@aws-sdk/client-s3": "^3.620.0",
6464
"@azure/storage-blob": "^12.24.0",
65-
"@google-cloud/storage": "^6.11.0"
65+
"@google-cloud/storage": "^6.11.0",
66+
"minio": "^8.0.1"
6667
},
6768
"directories": {
6869
"lib": "./src",

src/s3/minio.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { join } from "node:path";
2+
import { buffer } from "node:stream/consumers";
3+
4+
import * as Minio from "minio";
5+
import { Readable, Writable } from "stream";
6+
7+
import { ConnectionString } from "../connectionString";
8+
import type { IObjectStorage, PutOptions, StatResponse } from "../interface";
9+
import { UnimplementedError } from "../errors";
10+
11+
export class MinioStorage implements IObjectStorage {
12+
private readonly client: Minio.Client;
13+
private readonly bucketName: string;
14+
15+
constructor(config: ConnectionString) {
16+
const clientOptions: Minio.ClientOptions = {
17+
endPoint: "",
18+
accessKey: "",
19+
secretKey: ""
20+
};
21+
if (config.username !== undefined && config.username !== "" && config.password !== undefined && config.password !== "") {
22+
clientOptions.accessKey = config.username;
23+
clientOptions.secretKey = config.password;
24+
}
25+
26+
if (config.parameters !== undefined) {
27+
if ("region" in config.parameters && typeof config.parameters.region === "string") {
28+
clientOptions.region = config.parameters.region;
29+
} else {
30+
// See https://github.com/aws/aws-sdk-js-v3/issues/1845#issuecomment-754832210
31+
clientOptions.region = "us-east-1";
32+
clientOptions.pathStyle = true;
33+
}
34+
35+
if ("forcePathStyle" in config.parameters) {
36+
clientOptions.pathStyle = Boolean(config.parameters.forcePathStyle);
37+
}
38+
39+
if ("useAccelerateEndpoint" in config.parameters) {
40+
clientOptions.s3AccelerateEndpoint = config.parameters.useAccelerateEndpoint;
41+
}
42+
43+
if ("endpoint" in config.parameters && typeof config.parameters.endpoint === "string") {
44+
clientOptions.endPoint = config.parameters.endpoint;
45+
}
46+
47+
if ("useSSL" in config.parameters) {
48+
clientOptions.useSSL = Boolean(config.parameters.useSSL);
49+
}
50+
51+
if ("port" in config.parameters) {
52+
clientOptions.port = Number.parseInt(config.parameters.port);
53+
}
54+
}
55+
56+
this.client = new Minio.Client(clientOptions);
57+
this.bucketName = config.bucketName;
58+
}
59+
60+
async put(path: string, content: string | Buffer, options?: PutOptions | undefined): Promise<void> {
61+
await this.client.putObject(this.bucketName, path, content, undefined, options?.metadata);
62+
}
63+
64+
putStream(path: string, options?: PutOptions | undefined): Promise<Writable> {
65+
throw new UnimplementedError();
66+
}
67+
68+
async get(path: string, encoding?: string | undefined): Promise<Buffer> {
69+
const response = await this.client.getObject(this.bucketName, path);
70+
return buffer(response);
71+
}
72+
73+
getStream(path: string): Promise<Readable> {
74+
return this.client.getObject(this.bucketName, path);
75+
}
76+
77+
async stat(path: string): Promise<StatResponse> {
78+
const response = await this.client.statObject(this.bucketName, path);
79+
return {
80+
size: response.size,
81+
lastModified: response.lastModified,
82+
createdTime: new Date(0),
83+
etag: response.etag,
84+
metadata: response.metaData
85+
};
86+
}
87+
88+
list(path?: string | undefined): Promise<Iterable<string>> {
89+
return new Promise((resolve, reject) => {
90+
const listStream = this.client.listObjectsV2(this.bucketName, path, false);
91+
const objects: string[] = [];
92+
listStream.on("end", () => {
93+
resolve(objects);
94+
});
95+
96+
listStream.on("data", (item) => {
97+
if (item.name !== undefined) {
98+
objects.push(item.name);
99+
}
100+
});
101+
102+
listStream.on("error", (error) => {
103+
reject(error);
104+
});
105+
});
106+
}
107+
108+
async exists(path: string): Promise<boolean> {
109+
try {
110+
await this.client.statObject(this.bucketName, path);
111+
return true;
112+
} catch (error: unknown) {
113+
if (error instanceof Minio.S3Error) {
114+
if (error.code === "NoSuchKey") {
115+
return false;
116+
}
117+
}
118+
119+
throw error;
120+
}
121+
}
122+
123+
async delete(path: string): Promise<void> {
124+
await this.client.removeObject(this.bucketName, path);
125+
}
126+
127+
async copy(sourcePath: string, destinationPath: string): Promise<void> {
128+
await this.client.copyObject(this.bucketName, destinationPath, join(this.bucketName, sourcePath));
129+
}
130+
131+
async move(sourcePath: string, destinationPath: string): Promise<void> {
132+
await this.copy(sourcePath, destinationPath);
133+
await this.delete(sourcePath);
134+
}
135+
}

src/storage.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type IObjectStorage, PutOptions, StatResponse } from "./interface";
33
import { parseConnectionString } from "./connectionString";
44
import { FileStorage } from "./file/file";
55
import { S3Storage } from "./s3/s3";
6+
import { MinioStorage } from "./s3/minio";
67

78
/**
89
* The Storage class implements the `IObjectStorage` interface and provides a way to interact
@@ -40,7 +41,11 @@ export class Storage implements IObjectStorage {
4041
this.#implementation = new FileStorage(parsedConnectionString.bucketName);
4142
break;
4243
case "s3":
43-
this.#implementation = new S3Storage(parsedConnectionString);
44+
if (parsedConnectionString.parameters?.useMinioSdk === "true") {
45+
this.#implementation = new MinioStorage(parsedConnectionString);
46+
} else {
47+
this.#implementation = new S3Storage(parsedConnectionString);
48+
}
4449
break;
4550
// case "azblob":
4651
// this.#implementation = new AzureBlobStorage(parsedConnectionString);

tests/s3/integration.test.ts renamed to tests/s3/aws.integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterAll, beforeAll, describe, expect, it } from "vitest";
2-
import { destroyBucket, removeAllObject, setupBucket } from "./util";
2+
import { destroyBucket, removeAllObject, setupBucket } from "./aws.util";
33
import { S3Client } from "@aws-sdk/client-s3";
44
import { loremIpsum } from "lorem-ipsum";
55
import { createHash } from "node:crypto";
File renamed without changes.

tests/s3/minio.integration.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
2+
import { destroyBucket, removeAllObject, setupBucket } from "./minio.util";
3+
import { loremIpsum } from "lorem-ipsum";
4+
import { createHash } from "node:crypto";
5+
import { ConnectionString } from "../../src/connectionString";
6+
import { MinioStorage } from "../../src/s3/minio";
7+
import { Client } from "minio";
8+
9+
describe("S3 Provider - Integration", () => {
10+
const s3Host = process.env.S3_HOST ?? "http://localhost:9000";
11+
const s3Access = process.env.S3_ACCESS ?? "teknologi-umum";
12+
const s3Secret = process.env.S3_SECRET ?? "very-strong-password";
13+
const bucketName = "blob-js";
14+
const s3Client = new Client({
15+
endPoint: s3Host,
16+
accessKey: s3Access,
17+
secretKey: s3Secret,
18+
useSSL: false,
19+
pathStyle: true,
20+
region: "us-east-1"
21+
});
22+
23+
const connectionStringConfig: ConnectionString = {
24+
provider: "s3",
25+
username: s3Access,
26+
password: s3Secret,
27+
bucketName: bucketName,
28+
parameters: {
29+
useMinioSdk: "true",
30+
endpoint: s3Host,
31+
disableHostPrefix: "true",
32+
forcePathStyle: "true"
33+
}
34+
};
35+
36+
beforeAll(async () => {
37+
// Create S3 bucket
38+
await setupBucket(s3Client, bucketName);
39+
});
40+
41+
afterAll(async () => {
42+
await removeAllObject(s3Client, bucketName);
43+
await destroyBucket(s3Client, bucketName);
44+
});
45+
46+
it("should be able to create, read and delete file", async () => {
47+
const content = loremIpsum({count: 1024, units: "sentences"});
48+
const hashFunc = createHash("md5");
49+
hashFunc.update(content);
50+
const checksum = hashFunc.digest("base64");
51+
52+
const s3Client = new MinioStorage(connectionStringConfig);
53+
54+
await s3Client.put("lorem-ipsum.txt", content, {contentMD5: checksum});
55+
56+
expect(s3Client.exists("lorem-ipsum.txt"))
57+
.resolves
58+
.toStrictEqual(true);
59+
60+
// GetObjectAttributes is not available on MinIO
61+
// const fileStat = await s3Client.stat("lorem-ipsum.txt");
62+
// expect(fileStat.size).toStrictEqual(content.length);
63+
64+
const fileContent = await s3Client.get("lorem-ipsum.txt");
65+
expect(fileContent.toString()).toStrictEqual(content);
66+
67+
expect(s3Client.delete("lorem-ipsum.txt"))
68+
.resolves
69+
.ok;
70+
});
71+
});

tests/s3/minio.util.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
BucketAlreadyExists,
3+
BucketAlreadyOwnedByYou,
4+
CreateBucketCommand,
5+
DeleteBucketCommand,
6+
DeleteObjectsCommand,
7+
ListObjectsV2Command,
8+
S3Client
9+
} from "@aws-sdk/client-s3";
10+
import { Client } from "minio";
11+
12+
export async function setupBucket(client: Client, bucketName: string): Promise<void> {
13+
await client.makeBucket(bucketName);
14+
}
15+
16+
export async function removeAllObject(client: Client, bucketName: string): Promise<void> {
17+
const listObj = client.listObjectsV2(bucketName);
18+
19+
return new Promise((resolve, reject) => {
20+
listObj.on("data", async (obj) => {
21+
if (obj.name !== undefined) {
22+
await client.removeObject(bucketName, obj.name);
23+
}
24+
});
25+
26+
listObj.on("error", (error) => {
27+
reject(error);
28+
});
29+
30+
listObj.on("end", () => {
31+
resolve();
32+
});
33+
});
34+
}
35+
36+
export async function destroyBucket(client: Client, bucketName: string): Promise<void> {
37+
await client.removeBucket(bucketName);
38+
}

tests/s3/unit.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
44
import { BlobFileNotExistError, BlobMismatchedMD5IntegrityError } from "../../src/errors";
55
import { S3Client } from "@aws-sdk/client-s3";
66
import { ConnectionString } from "../../src/connectionString";
7-
import { destroyBucket, removeAllObject, setupBucket } from "./util";
7+
import { destroyBucket, removeAllObject, setupBucket } from "./aws.util";
88
import { S3Storage } from "../../src/s3/s3";
99

1010
describe("S3 Provider - Unit", () => {

0 commit comments

Comments
 (0)