@oino-ts/blob-aws 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ /*
3
+ * This Source Code Form is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.OINOBlobAwsS3 = void 0;
9
+ const client_s3_1 = require("@aws-sdk/client-s3");
10
+ const common_1 = require("@oino-ts/common");
11
+ const blob_1 = require("@oino-ts/blob");
12
+ /**
13
+ * AWS S3 (and S3-compatible) implementation of `OINOBlob`.
14
+ *
15
+ * Authenticates using static access key credentials supplied via a JSON-encoded
16
+ * connection string. Connection parameters map as:
17
+ * - `params.url` → optional custom endpoint, e.g. `https://s3.eu-west-1.amazonaws.com`
18
+ * or a compatible service such as MinIO / Cloudflare R2
19
+ * - `params.container` → S3 bucket name
20
+ * - `params.connectionStr` → JSON string: `{"region":"…","accessKeyId":"…","secretAccessKey":"…"}`
21
+ *
22
+ * Register and use via the factory:
23
+ * ```ts
24
+ * import { OINOBlobFactory } from "@oino-ts/blob"
25
+ * import { OINOBlobAwsS3 } from "@oino-ts/blob-aws"
26
+ *
27
+ * OINOBlobFactory.registerBlob("OINOBlobAwsS3", OINOBlobAwsS3)
28
+ *
29
+ * const blob = await OINOBlobFactory.createBlob({
30
+ * type: "OINOBlobAwsS3",
31
+ * url: "", // leave empty for default AWS endpoint
32
+ * container: "my-bucket",
33
+ * connectionStr: JSON.stringify({
34
+ * region: "us-east-1",
35
+ * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
36
+ * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
37
+ * })
38
+ * })
39
+ * ```
40
+ */
41
+ class OINOBlobAwsS3 extends blob_1.OINOBlob {
42
+ _s3Client = null;
43
+ // ── OINODataSource lifecycle ──────────────────────────────────────────
44
+ /**
45
+ * Initialise the AWS SDK S3 client from the JSON-encoded `connectionStr`.
46
+ * Does not perform any network call.
47
+ */
48
+ async connect() {
49
+ if (!this.blobParams.connectionStr) {
50
+ return new common_1.OINOResult({
51
+ success: false,
52
+ status: 400,
53
+ statusText: "OINOBlobAwsS3: params.connectionStr is required (JSON with region, accessKeyId, secretAccessKey)"
54
+ });
55
+ }
56
+ let creds;
57
+ try {
58
+ creds = JSON.parse(this.blobParams.connectionStr);
59
+ }
60
+ catch {
61
+ return new common_1.OINOResult({
62
+ success: false,
63
+ status: 400,
64
+ statusText: "OINOBlobAwsS3: params.connectionStr must be a valid JSON string"
65
+ });
66
+ }
67
+ if (!creds.region || !creds.accessKeyId || !creds.secretAccessKey) {
68
+ return new common_1.OINOResult({
69
+ success: false,
70
+ status: 400,
71
+ statusText: "OINOBlobAwsS3: connectionStr must include region, accessKeyId and secretAccessKey"
72
+ });
73
+ }
74
+ try {
75
+ const clientConfig = {
76
+ region: creds.region,
77
+ credentials: {
78
+ accessKeyId: creds.accessKeyId,
79
+ secretAccessKey: creds.secretAccessKey
80
+ }
81
+ };
82
+ if (this.blobParams.url) {
83
+ clientConfig.endpoint = this.blobParams.url;
84
+ clientConfig.forcePathStyle = true;
85
+ }
86
+ this._s3Client = new client_s3_1.S3Client(clientConfig);
87
+ this.isConnected = true;
88
+ }
89
+ catch (e) {
90
+ return new common_1.OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3 connect failed: " + e.message });
91
+ }
92
+ return new common_1.OINOResult();
93
+ }
94
+ /**
95
+ * Verify that the target bucket exists and is accessible using a `HeadBucket` call.
96
+ */
97
+ async validate() {
98
+ if (!this._s3Client) {
99
+ return new common_1.OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3: not connected" });
100
+ }
101
+ try {
102
+ await this._s3Client.send(new client_s3_1.HeadBucketCommand({ Bucket: this.blobParams.container }));
103
+ this.isValidated = true;
104
+ }
105
+ catch (e) {
106
+ console.error("OINOBlobAwsS3 validate error:", e);
107
+ const status = e.$metadata?.httpStatusCode ?? 500;
108
+ if (status === 404) {
109
+ return new common_1.OINOResult({
110
+ success: false,
111
+ status: 404,
112
+ statusText: "OINOBlobAwsS3: bucket '" + this.blobParams.container + "' not found"
113
+ });
114
+ }
115
+ else if (status === 403) {
116
+ return new common_1.OINOResult({
117
+ success: false,
118
+ status: 403,
119
+ statusText: "OINOBlobAwsS3: access to bucket '" + this.blobParams.container + "' forbidden (check credentials and permissions)"
120
+ });
121
+ }
122
+ else {
123
+ return new common_1.OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3 validate failed: " + e.message });
124
+ }
125
+ }
126
+ return new common_1.OINOResult();
127
+ }
128
+ /**
129
+ * Release the S3 client (destroys the underlying HTTP connection pool).
130
+ */
131
+ async disconnect() {
132
+ this._s3Client?.destroy();
133
+ this._s3Client = null;
134
+ this.isConnected = false;
135
+ this.isValidated = false;
136
+ }
137
+ // ── OINOBlob operations ───────────────────────────────────────────────
138
+ /**
139
+ * List all objects in the bucket, applying a server-side S3 `Prefix` filter
140
+ * where possible and in-memory result filtering for all other predicates.
141
+ *
142
+ * - The `name` field supports server-side prefix filtering via `ListObjectsV2`
143
+ * `Prefix` option (query filtering).
144
+ * - All other field predicates (`etag`, `lastModified`, `contentLength`,
145
+ * `contentType`) are evaluated in-memory after the listing (result filtering).
146
+ * Note: S3 listing does not return `contentType`; it defaults to
147
+ * `"application/octet-stream"` unless a `contentType` filter is applied.
148
+ *
149
+ * @param filter optional query filter to apply
150
+ */
151
+ async listEntries(filter) {
152
+ if (!this._s3Client) {
153
+ throw new Error("OINOBlobAwsS3: not connected");
154
+ }
155
+ const queryPrefix = (filter && !filter.isEmpty())
156
+ ? blob_1.OINOBlob.extractNamePrefix(filter)
157
+ : undefined;
158
+ const entries = [];
159
+ let continuationToken;
160
+ do {
161
+ const response = await this._s3Client.send(new client_s3_1.ListObjectsV2Command({
162
+ Bucket: this.blobParams.container,
163
+ Prefix: queryPrefix,
164
+ ContinuationToken: continuationToken
165
+ }));
166
+ for (const obj of response.Contents ?? []) {
167
+ entries.push({
168
+ name: obj.Key ?? "",
169
+ etag: (obj.ETag ?? "").replace(/^"|"$/g, ""),
170
+ lastModified: obj.LastModified ?? new Date(0),
171
+ contentLength: obj.Size ?? 0,
172
+ contentType: "application/octet-stream" // S3 does not return content type in listing, default to binary
173
+ });
174
+ }
175
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
176
+ } while (continuationToken);
177
+ if (!filter || filter.isEmpty()) {
178
+ return entries;
179
+ }
180
+ return entries.filter(e => blob_1.OINOBlob.matchesEntry(e, filter));
181
+ }
182
+ /**
183
+ * Download the raw content of a named object.
184
+ *
185
+ * @param name full object key (path within the bucket)
186
+ */
187
+ async fetchEntry(name) {
188
+ if (!this._s3Client) {
189
+ throw new Error("OINOBlobAwsS3: not connected");
190
+ }
191
+ const response = await this._s3Client.send(new client_s3_1.GetObjectCommand({
192
+ Bucket: this.blobParams.container,
193
+ Key: name
194
+ }));
195
+ const contentType = response.ContentType ?? "application/octet-stream";
196
+ if (!response.Body) {
197
+ throw new Error("OINOBlobAwsS3: no body returned for object '" + name + "'");
198
+ }
199
+ const content = await response.Body.transformToByteArray();
200
+ return { content, contentType };
201
+ }
202
+ /**
203
+ * Upload (create or replace) an object with the given binary content.
204
+ *
205
+ * @param name full object key (path within the bucket)
206
+ * @param content binary content to store
207
+ * @param contentType MIME type of the content (e.g. `"image/jpeg"`)
208
+ */
209
+ async uploadEntry(name, content, contentType) {
210
+ if (!this._s3Client) {
211
+ throw new Error("OINOBlobAwsS3: not connected");
212
+ }
213
+ await this._s3Client.send(new client_s3_1.PutObjectCommand({
214
+ Bucket: this.blobParams.container,
215
+ Key: name,
216
+ Body: content,
217
+ ContentType: contentType
218
+ }));
219
+ }
220
+ /**
221
+ * Delete a named object.
222
+ *
223
+ * @param name full object key (path within the bucket)
224
+ */
225
+ async deleteEntry(name) {
226
+ if (!this._s3Client) {
227
+ throw new Error("OINOBlobAwsS3: not connected");
228
+ }
229
+ await this._s3Client.send(new client_s3_1.DeleteObjectCommand({
230
+ Bucket: this.blobParams.container,
231
+ Key: name
232
+ }));
233
+ }
234
+ // ── OINODataSource datamodel initialisation ───────────────────────────
235
+ /**
236
+ * Attach a static `OINOBlobDataModel` to the given API, adding only the
237
+ * four fields that S3 object listings return (`contentType` is omitted
238
+ * because S3 does not include it in listing responses).
239
+ *
240
+ * @param api the `OINOBlobApi` whose data model is to be initialised
241
+ */
242
+ async initializeApiDatamodel(api) {
243
+ const blobApi = api;
244
+ const datamodel = new blob_1.OINOBlobDataModel(blobApi);
245
+ const ds = this;
246
+ const FIELD = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false };
247
+ const PK = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true };
248
+ datamodel.addField(new common_1.OINOStringDataField(ds, "name", "TEXT", PK, 1024));
249
+ datamodel.addField(new common_1.OINOStringDataField(ds, "etag", "TEXT", FIELD, 256));
250
+ datamodel.addField(new common_1.OINODatetimeDataField(ds, "lastModified", "DATETIME", FIELD));
251
+ datamodel.addField(new common_1.OINONumberDataField(ds, "contentLength", "INTEGER", FIELD));
252
+ blobApi.initializeDatamodel(datamodel);
253
+ }
254
+ }
255
+ exports.OINOBlobAwsS3 = OINOBlobAwsS3;
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OINOBlobAwsS3 = void 0;
4
+ var OINOBlobAwsS3_js_1 = require("./OINOBlobAwsS3.js");
5
+ Object.defineProperty(exports, "OINOBlobAwsS3", { enumerable: true, get: function () { return OINOBlobAwsS3_js_1.OINOBlobAwsS3; } });
@@ -0,0 +1,251 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+ import { S3Client, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
7
+ import { OINOResult, OINOStringDataField, OINONumberDataField, OINODatetimeDataField } from "@oino-ts/common";
8
+ import { OINOBlob, OINOBlobDataModel } from "@oino-ts/blob";
9
+ /**
10
+ * AWS S3 (and S3-compatible) implementation of `OINOBlob`.
11
+ *
12
+ * Authenticates using static access key credentials supplied via a JSON-encoded
13
+ * connection string. Connection parameters map as:
14
+ * - `params.url` → optional custom endpoint, e.g. `https://s3.eu-west-1.amazonaws.com`
15
+ * or a compatible service such as MinIO / Cloudflare R2
16
+ * - `params.container` → S3 bucket name
17
+ * - `params.connectionStr` → JSON string: `{"region":"…","accessKeyId":"…","secretAccessKey":"…"}`
18
+ *
19
+ * Register and use via the factory:
20
+ * ```ts
21
+ * import { OINOBlobFactory } from "@oino-ts/blob"
22
+ * import { OINOBlobAwsS3 } from "@oino-ts/blob-aws"
23
+ *
24
+ * OINOBlobFactory.registerBlob("OINOBlobAwsS3", OINOBlobAwsS3)
25
+ *
26
+ * const blob = await OINOBlobFactory.createBlob({
27
+ * type: "OINOBlobAwsS3",
28
+ * url: "", // leave empty for default AWS endpoint
29
+ * container: "my-bucket",
30
+ * connectionStr: JSON.stringify({
31
+ * region: "us-east-1",
32
+ * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
33
+ * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
34
+ * })
35
+ * })
36
+ * ```
37
+ */
38
+ export class OINOBlobAwsS3 extends OINOBlob {
39
+ _s3Client = null;
40
+ // ── OINODataSource lifecycle ──────────────────────────────────────────
41
+ /**
42
+ * Initialise the AWS SDK S3 client from the JSON-encoded `connectionStr`.
43
+ * Does not perform any network call.
44
+ */
45
+ async connect() {
46
+ if (!this.blobParams.connectionStr) {
47
+ return new OINOResult({
48
+ success: false,
49
+ status: 400,
50
+ statusText: "OINOBlobAwsS3: params.connectionStr is required (JSON with region, accessKeyId, secretAccessKey)"
51
+ });
52
+ }
53
+ let creds;
54
+ try {
55
+ creds = JSON.parse(this.blobParams.connectionStr);
56
+ }
57
+ catch {
58
+ return new OINOResult({
59
+ success: false,
60
+ status: 400,
61
+ statusText: "OINOBlobAwsS3: params.connectionStr must be a valid JSON string"
62
+ });
63
+ }
64
+ if (!creds.region || !creds.accessKeyId || !creds.secretAccessKey) {
65
+ return new OINOResult({
66
+ success: false,
67
+ status: 400,
68
+ statusText: "OINOBlobAwsS3: connectionStr must include region, accessKeyId and secretAccessKey"
69
+ });
70
+ }
71
+ try {
72
+ const clientConfig = {
73
+ region: creds.region,
74
+ credentials: {
75
+ accessKeyId: creds.accessKeyId,
76
+ secretAccessKey: creds.secretAccessKey
77
+ }
78
+ };
79
+ if (this.blobParams.url) {
80
+ clientConfig.endpoint = this.blobParams.url;
81
+ clientConfig.forcePathStyle = true;
82
+ }
83
+ this._s3Client = new S3Client(clientConfig);
84
+ this.isConnected = true;
85
+ }
86
+ catch (e) {
87
+ return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3 connect failed: " + e.message });
88
+ }
89
+ return new OINOResult();
90
+ }
91
+ /**
92
+ * Verify that the target bucket exists and is accessible using a `HeadBucket` call.
93
+ */
94
+ async validate() {
95
+ if (!this._s3Client) {
96
+ return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3: not connected" });
97
+ }
98
+ try {
99
+ await this._s3Client.send(new HeadBucketCommand({ Bucket: this.blobParams.container }));
100
+ this.isValidated = true;
101
+ }
102
+ catch (e) {
103
+ console.error("OINOBlobAwsS3 validate error:", e);
104
+ const status = e.$metadata?.httpStatusCode ?? 500;
105
+ if (status === 404) {
106
+ return new OINOResult({
107
+ success: false,
108
+ status: 404,
109
+ statusText: "OINOBlobAwsS3: bucket '" + this.blobParams.container + "' not found"
110
+ });
111
+ }
112
+ else if (status === 403) {
113
+ return new OINOResult({
114
+ success: false,
115
+ status: 403,
116
+ statusText: "OINOBlobAwsS3: access to bucket '" + this.blobParams.container + "' forbidden (check credentials and permissions)"
117
+ });
118
+ }
119
+ else {
120
+ return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3 validate failed: " + e.message });
121
+ }
122
+ }
123
+ return new OINOResult();
124
+ }
125
+ /**
126
+ * Release the S3 client (destroys the underlying HTTP connection pool).
127
+ */
128
+ async disconnect() {
129
+ this._s3Client?.destroy();
130
+ this._s3Client = null;
131
+ this.isConnected = false;
132
+ this.isValidated = false;
133
+ }
134
+ // ── OINOBlob operations ───────────────────────────────────────────────
135
+ /**
136
+ * List all objects in the bucket, applying a server-side S3 `Prefix` filter
137
+ * where possible and in-memory result filtering for all other predicates.
138
+ *
139
+ * - The `name` field supports server-side prefix filtering via `ListObjectsV2`
140
+ * `Prefix` option (query filtering).
141
+ * - All other field predicates (`etag`, `lastModified`, `contentLength`,
142
+ * `contentType`) are evaluated in-memory after the listing (result filtering).
143
+ * Note: S3 listing does not return `contentType`; it defaults to
144
+ * `"application/octet-stream"` unless a `contentType` filter is applied.
145
+ *
146
+ * @param filter optional query filter to apply
147
+ */
148
+ async listEntries(filter) {
149
+ if (!this._s3Client) {
150
+ throw new Error("OINOBlobAwsS3: not connected");
151
+ }
152
+ const queryPrefix = (filter && !filter.isEmpty())
153
+ ? OINOBlob.extractNamePrefix(filter)
154
+ : undefined;
155
+ const entries = [];
156
+ let continuationToken;
157
+ do {
158
+ const response = await this._s3Client.send(new ListObjectsV2Command({
159
+ Bucket: this.blobParams.container,
160
+ Prefix: queryPrefix,
161
+ ContinuationToken: continuationToken
162
+ }));
163
+ for (const obj of response.Contents ?? []) {
164
+ entries.push({
165
+ name: obj.Key ?? "",
166
+ etag: (obj.ETag ?? "").replace(/^"|"$/g, ""),
167
+ lastModified: obj.LastModified ?? new Date(0),
168
+ contentLength: obj.Size ?? 0,
169
+ contentType: "application/octet-stream" // S3 does not return content type in listing, default to binary
170
+ });
171
+ }
172
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
173
+ } while (continuationToken);
174
+ if (!filter || filter.isEmpty()) {
175
+ return entries;
176
+ }
177
+ return entries.filter(e => OINOBlob.matchesEntry(e, filter));
178
+ }
179
+ /**
180
+ * Download the raw content of a named object.
181
+ *
182
+ * @param name full object key (path within the bucket)
183
+ */
184
+ async fetchEntry(name) {
185
+ if (!this._s3Client) {
186
+ throw new Error("OINOBlobAwsS3: not connected");
187
+ }
188
+ const response = await this._s3Client.send(new GetObjectCommand({
189
+ Bucket: this.blobParams.container,
190
+ Key: name
191
+ }));
192
+ const contentType = response.ContentType ?? "application/octet-stream";
193
+ if (!response.Body) {
194
+ throw new Error("OINOBlobAwsS3: no body returned for object '" + name + "'");
195
+ }
196
+ const content = await response.Body.transformToByteArray();
197
+ return { content, contentType };
198
+ }
199
+ /**
200
+ * Upload (create or replace) an object with the given binary content.
201
+ *
202
+ * @param name full object key (path within the bucket)
203
+ * @param content binary content to store
204
+ * @param contentType MIME type of the content (e.g. `"image/jpeg"`)
205
+ */
206
+ async uploadEntry(name, content, contentType) {
207
+ if (!this._s3Client) {
208
+ throw new Error("OINOBlobAwsS3: not connected");
209
+ }
210
+ await this._s3Client.send(new PutObjectCommand({
211
+ Bucket: this.blobParams.container,
212
+ Key: name,
213
+ Body: content,
214
+ ContentType: contentType
215
+ }));
216
+ }
217
+ /**
218
+ * Delete a named object.
219
+ *
220
+ * @param name full object key (path within the bucket)
221
+ */
222
+ async deleteEntry(name) {
223
+ if (!this._s3Client) {
224
+ throw new Error("OINOBlobAwsS3: not connected");
225
+ }
226
+ await this._s3Client.send(new DeleteObjectCommand({
227
+ Bucket: this.blobParams.container,
228
+ Key: name
229
+ }));
230
+ }
231
+ // ── OINODataSource datamodel initialisation ───────────────────────────
232
+ /**
233
+ * Attach a static `OINOBlobDataModel` to the given API, adding only the
234
+ * four fields that S3 object listings return (`contentType` is omitted
235
+ * because S3 does not include it in listing responses).
236
+ *
237
+ * @param api the `OINOBlobApi` whose data model is to be initialised
238
+ */
239
+ async initializeApiDatamodel(api) {
240
+ const blobApi = api;
241
+ const datamodel = new OINOBlobDataModel(blobApi);
242
+ const ds = this;
243
+ const FIELD = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false };
244
+ const PK = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true };
245
+ datamodel.addField(new OINOStringDataField(ds, "name", "TEXT", PK, 1024));
246
+ datamodel.addField(new OINOStringDataField(ds, "etag", "TEXT", FIELD, 256));
247
+ datamodel.addField(new OINODatetimeDataField(ds, "lastModified", "DATETIME", FIELD));
248
+ datamodel.addField(new OINONumberDataField(ds, "contentLength", "INTEGER", FIELD));
249
+ blobApi.initializeDatamodel(datamodel);
250
+ }
251
+ }
@@ -0,0 +1 @@
1
+ export { OINOBlobAwsS3 } from "./OINOBlobAwsS3.js";
@@ -0,0 +1,90 @@
1
+ import { OINOApi, OINOResult, OINOQueryFilter } from "@oino-ts/common";
2
+ import { OINOBlob } from "@oino-ts/blob";
3
+ import { type OINOBlobEntry, type OINOBlobFetchResult } from "@oino-ts/blob";
4
+ /**
5
+ * AWS S3 (and S3-compatible) implementation of `OINOBlob`.
6
+ *
7
+ * Authenticates using static access key credentials supplied via a JSON-encoded
8
+ * connection string. Connection parameters map as:
9
+ * - `params.url` → optional custom endpoint, e.g. `https://s3.eu-west-1.amazonaws.com`
10
+ * or a compatible service such as MinIO / Cloudflare R2
11
+ * - `params.container` → S3 bucket name
12
+ * - `params.connectionStr` → JSON string: `{"region":"…","accessKeyId":"…","secretAccessKey":"…"}`
13
+ *
14
+ * Register and use via the factory:
15
+ * ```ts
16
+ * import { OINOBlobFactory } from "@oino-ts/blob"
17
+ * import { OINOBlobAwsS3 } from "@oino-ts/blob-aws"
18
+ *
19
+ * OINOBlobFactory.registerBlob("OINOBlobAwsS3", OINOBlobAwsS3)
20
+ *
21
+ * const blob = await OINOBlobFactory.createBlob({
22
+ * type: "OINOBlobAwsS3",
23
+ * url: "", // leave empty for default AWS endpoint
24
+ * container: "my-bucket",
25
+ * connectionStr: JSON.stringify({
26
+ * region: "us-east-1",
27
+ * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
28
+ * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
29
+ * })
30
+ * })
31
+ * ```
32
+ */
33
+ export declare class OINOBlobAwsS3 extends OINOBlob {
34
+ private _s3Client;
35
+ /**
36
+ * Initialise the AWS SDK S3 client from the JSON-encoded `connectionStr`.
37
+ * Does not perform any network call.
38
+ */
39
+ connect(): Promise<OINOResult>;
40
+ /**
41
+ * Verify that the target bucket exists and is accessible using a `HeadBucket` call.
42
+ */
43
+ validate(): Promise<OINOResult>;
44
+ /**
45
+ * Release the S3 client (destroys the underlying HTTP connection pool).
46
+ */
47
+ disconnect(): Promise<void>;
48
+ /**
49
+ * List all objects in the bucket, applying a server-side S3 `Prefix` filter
50
+ * where possible and in-memory result filtering for all other predicates.
51
+ *
52
+ * - The `name` field supports server-side prefix filtering via `ListObjectsV2`
53
+ * `Prefix` option (query filtering).
54
+ * - All other field predicates (`etag`, `lastModified`, `contentLength`,
55
+ * `contentType`) are evaluated in-memory after the listing (result filtering).
56
+ * Note: S3 listing does not return `contentType`; it defaults to
57
+ * `"application/octet-stream"` unless a `contentType` filter is applied.
58
+ *
59
+ * @param filter optional query filter to apply
60
+ */
61
+ listEntries(filter?: OINOQueryFilter): Promise<OINOBlobEntry[]>;
62
+ /**
63
+ * Download the raw content of a named object.
64
+ *
65
+ * @param name full object key (path within the bucket)
66
+ */
67
+ fetchEntry(name: string): Promise<OINOBlobFetchResult>;
68
+ /**
69
+ * Upload (create or replace) an object with the given binary content.
70
+ *
71
+ * @param name full object key (path within the bucket)
72
+ * @param content binary content to store
73
+ * @param contentType MIME type of the content (e.g. `"image/jpeg"`)
74
+ */
75
+ uploadEntry(name: string, content: Uint8Array, contentType: string): Promise<void>;
76
+ /**
77
+ * Delete a named object.
78
+ *
79
+ * @param name full object key (path within the bucket)
80
+ */
81
+ deleteEntry(name: string): Promise<void>;
82
+ /**
83
+ * Attach a static `OINOBlobDataModel` to the given API, adding only the
84
+ * four fields that S3 object listings return (`contentType` is omitted
85
+ * because S3 does not include it in listing responses).
86
+ *
87
+ * @param api the `OINOBlobApi` whose data model is to be initialised
88
+ */
89
+ initializeApiDatamodel(api: OINOApi): Promise<void>;
90
+ }
@@ -0,0 +1 @@
1
+ export { OINOBlobAwsS3 } from "./OINOBlobAwsS3.js";
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@oino-ts/blob-aws",
3
+ "version": "1.0.0",
4
+ "description": "OINO TS package for using AWS S3 (or S3-compatible) storage as a REST API.",
5
+ "author": "Matias Kiviniemi (pragmatta)",
6
+ "license": "MPL-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/pragmatta/oino-ts.git"
10
+ },
11
+ "keywords": [
12
+ "blob",
13
+ "storage",
14
+ "rest-api",
15
+ "typescript",
16
+ "library",
17
+ "s3",
18
+ "aws"
19
+ ],
20
+ "main": "./dist/cjs/index.js",
21
+ "module": "./dist/esm/index.js",
22
+ "types": "./dist/types/index.d.ts",
23
+ "dependencies": {
24
+ "@aws-sdk/client-s3": "^3.0.0",
25
+ "@oino-ts/blob": "1.0.0",
26
+ "@oino-ts/common": "1.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@oino-ts/types": "1.0.0",
30
+ "@types/bun": "^1.1.14",
31
+ "@types/node": "^22.0.00",
32
+ "typescript": "~5.9.0"
33
+ },
34
+ "files": [
35
+ "src/*.ts",
36
+ "dist/cjs/*.js",
37
+ "dist/esm/*.js",
38
+ "dist/types/*.d.ts"
39
+ ]
40
+ }
@@ -0,0 +1,277 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import {
8
+ S3Client,
9
+ HeadBucketCommand,
10
+ ListObjectsV2Command,
11
+ GetObjectCommand,
12
+ PutObjectCommand,
13
+ DeleteObjectCommand
14
+ } from "@aws-sdk/client-s3"
15
+
16
+ import { OINOApi, OINOResult, OINOQueryFilter, OINOStringDataField, OINONumberDataField, OINODatetimeDataField, type OINODataFieldParams } from "@oino-ts/common"
17
+ import { OINOBlob, OINOBlobDataModel, OINOBlobApi } from "@oino-ts/blob"
18
+ import { type OINOBlobEntry, type OINOBlobFetchResult } from "@oino-ts/blob"
19
+
20
+ /**
21
+ * AWS S3 (and S3-compatible) implementation of `OINOBlob`.
22
+ *
23
+ * Authenticates using static access key credentials supplied via a JSON-encoded
24
+ * connection string. Connection parameters map as:
25
+ * - `params.url` → optional custom endpoint, e.g. `https://s3.eu-west-1.amazonaws.com`
26
+ * or a compatible service such as MinIO / Cloudflare R2
27
+ * - `params.container` → S3 bucket name
28
+ * - `params.connectionStr` → JSON string: `{"region":"…","accessKeyId":"…","secretAccessKey":"…"}`
29
+ *
30
+ * Register and use via the factory:
31
+ * ```ts
32
+ * import { OINOBlobFactory } from "@oino-ts/blob"
33
+ * import { OINOBlobAwsS3 } from "@oino-ts/blob-aws"
34
+ *
35
+ * OINOBlobFactory.registerBlob("OINOBlobAwsS3", OINOBlobAwsS3)
36
+ *
37
+ * const blob = await OINOBlobFactory.createBlob({
38
+ * type: "OINOBlobAwsS3",
39
+ * url: "", // leave empty for default AWS endpoint
40
+ * container: "my-bucket",
41
+ * connectionStr: JSON.stringify({
42
+ * region: "us-east-1",
43
+ * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
44
+ * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
45
+ * })
46
+ * })
47
+ * ```
48
+ */
49
+ export class OINOBlobAwsS3 extends OINOBlob {
50
+ private _s3Client: S3Client | null = null
51
+
52
+ // ── OINODataSource lifecycle ──────────────────────────────────────────
53
+
54
+ /**
55
+ * Initialise the AWS SDK S3 client from the JSON-encoded `connectionStr`.
56
+ * Does not perform any network call.
57
+ */
58
+ async connect(): Promise<OINOResult> {
59
+ if (!this.blobParams.connectionStr) {
60
+ return new OINOResult({
61
+ success: false,
62
+ status: 400,
63
+ statusText: "OINOBlobAwsS3: params.connectionStr is required (JSON with region, accessKeyId, secretAccessKey)"
64
+ })
65
+ }
66
+
67
+ let creds: { region: string; accessKeyId: string; secretAccessKey: string }
68
+ try {
69
+ creds = JSON.parse(this.blobParams.connectionStr)
70
+ } catch {
71
+ return new OINOResult({
72
+ success: false,
73
+ status: 400,
74
+ statusText: "OINOBlobAwsS3: params.connectionStr must be a valid JSON string"
75
+ })
76
+ }
77
+
78
+ if (!creds.region || !creds.accessKeyId || !creds.secretAccessKey) {
79
+ return new OINOResult({
80
+ success: false,
81
+ status: 400,
82
+ statusText: "OINOBlobAwsS3: connectionStr must include region, accessKeyId and secretAccessKey"
83
+ })
84
+ }
85
+
86
+ try {
87
+ const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
88
+ region: creds.region,
89
+ credentials: {
90
+ accessKeyId: creds.accessKeyId,
91
+ secretAccessKey: creds.secretAccessKey
92
+ }
93
+ }
94
+ if (this.blobParams.url) {
95
+ clientConfig.endpoint = this.blobParams.url
96
+ clientConfig.forcePathStyle = true
97
+ }
98
+ this._s3Client = new S3Client(clientConfig)
99
+ this.isConnected = true
100
+ } catch (e: any) {
101
+ return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3 connect failed: " + e.message })
102
+ }
103
+ return new OINOResult()
104
+ }
105
+
106
+ /**
107
+ * Verify that the target bucket exists and is accessible using a `HeadBucket` call.
108
+ */
109
+ async validate(): Promise<OINOResult> {
110
+ if (!this._s3Client) {
111
+ return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3: not connected" })
112
+ }
113
+ try {
114
+ await this._s3Client.send(new HeadBucketCommand({ Bucket: this.blobParams.container }))
115
+ this.isValidated = true
116
+ } catch (e: any) {
117
+ console.error("OINOBlobAwsS3 validate error:", e)
118
+ const status = e.$metadata?.httpStatusCode ?? 500
119
+ if (status === 404) {
120
+ return new OINOResult({
121
+ success: false,
122
+ status: 404,
123
+ statusText: "OINOBlobAwsS3: bucket '" + this.blobParams.container + "' not found"
124
+ })
125
+ } else if (status === 403) {
126
+ return new OINOResult({
127
+ success: false,
128
+ status: 403,
129
+ statusText: "OINOBlobAwsS3: access to bucket '" + this.blobParams.container + "' forbidden (check credentials and permissions)"
130
+ })
131
+ } else {
132
+ return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAwsS3 validate failed: " + e.message })
133
+ }
134
+ }
135
+ return new OINOResult()
136
+ }
137
+
138
+ /**
139
+ * Release the S3 client (destroys the underlying HTTP connection pool).
140
+ */
141
+ async disconnect(): Promise<void> {
142
+ this._s3Client?.destroy()
143
+ this._s3Client = null
144
+ this.isConnected = false
145
+ this.isValidated = false
146
+ }
147
+
148
+ // ── OINOBlob operations ───────────────────────────────────────────────
149
+
150
+ /**
151
+ * List all objects in the bucket, applying a server-side S3 `Prefix` filter
152
+ * where possible and in-memory result filtering for all other predicates.
153
+ *
154
+ * - The `name` field supports server-side prefix filtering via `ListObjectsV2`
155
+ * `Prefix` option (query filtering).
156
+ * - All other field predicates (`etag`, `lastModified`, `contentLength`,
157
+ * `contentType`) are evaluated in-memory after the listing (result filtering).
158
+ * Note: S3 listing does not return `contentType`; it defaults to
159
+ * `"application/octet-stream"` unless a `contentType` filter is applied.
160
+ *
161
+ * @param filter optional query filter to apply
162
+ */
163
+ async listEntries(filter?: OINOQueryFilter): Promise<OINOBlobEntry[]> {
164
+ if (!this._s3Client) {
165
+ throw new Error("OINOBlobAwsS3: not connected")
166
+ }
167
+
168
+ const queryPrefix = (filter && !filter.isEmpty())
169
+ ? OINOBlob.extractNamePrefix(filter)
170
+ : undefined
171
+
172
+ const entries: OINOBlobEntry[] = []
173
+ let continuationToken: string | undefined
174
+
175
+ do {
176
+ const response = await this._s3Client.send(new ListObjectsV2Command({
177
+ Bucket: this.blobParams.container,
178
+ Prefix: queryPrefix,
179
+ ContinuationToken: continuationToken
180
+ }))
181
+
182
+ for (const obj of response.Contents ?? []) {
183
+ entries.push({
184
+ name: obj.Key ?? "",
185
+ etag: (obj.ETag ?? "").replace(/^"|"$/g, ""),
186
+ lastModified: obj.LastModified ?? new Date(0),
187
+ contentLength: obj.Size ?? 0,
188
+ contentType: "application/octet-stream" // S3 does not return content type in listing, default to binary
189
+ })
190
+ }
191
+
192
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
193
+ } while (continuationToken)
194
+
195
+ if (!filter || filter.isEmpty()) {
196
+ return entries
197
+ }
198
+ return entries.filter(e => OINOBlob.matchesEntry(e, filter))
199
+ }
200
+
201
+ /**
202
+ * Download the raw content of a named object.
203
+ *
204
+ * @param name full object key (path within the bucket)
205
+ */
206
+ async fetchEntry(name: string): Promise<OINOBlobFetchResult> {
207
+ if (!this._s3Client) {
208
+ throw new Error("OINOBlobAwsS3: not connected")
209
+ }
210
+ const response = await this._s3Client.send(new GetObjectCommand({
211
+ Bucket: this.blobParams.container,
212
+ Key: name
213
+ }))
214
+ const contentType = response.ContentType ?? "application/octet-stream"
215
+ if (!response.Body) {
216
+ throw new Error("OINOBlobAwsS3: no body returned for object '" + name + "'")
217
+ }
218
+ const content = await response.Body.transformToByteArray()
219
+ return { content, contentType }
220
+ }
221
+
222
+ /**
223
+ * Upload (create or replace) an object with the given binary content.
224
+ *
225
+ * @param name full object key (path within the bucket)
226
+ * @param content binary content to store
227
+ * @param contentType MIME type of the content (e.g. `"image/jpeg"`)
228
+ */
229
+ async uploadEntry(name: string, content: Uint8Array, contentType: string): Promise<void> {
230
+ if (!this._s3Client) {
231
+ throw new Error("OINOBlobAwsS3: not connected")
232
+ }
233
+ await this._s3Client.send(new PutObjectCommand({
234
+ Bucket: this.blobParams.container,
235
+ Key: name,
236
+ Body: content,
237
+ ContentType: contentType
238
+ }))
239
+ }
240
+
241
+ /**
242
+ * Delete a named object.
243
+ *
244
+ * @param name full object key (path within the bucket)
245
+ */
246
+ async deleteEntry(name: string): Promise<void> {
247
+ if (!this._s3Client) {
248
+ throw new Error("OINOBlobAwsS3: not connected")
249
+ }
250
+ await this._s3Client.send(new DeleteObjectCommand({
251
+ Bucket: this.blobParams.container,
252
+ Key: name
253
+ }))
254
+ }
255
+
256
+ // ── OINODataSource datamodel initialisation ───────────────────────────
257
+
258
+ /**
259
+ * Attach a static `OINOBlobDataModel` to the given API, adding only the
260
+ * four fields that S3 object listings return (`contentType` is omitted
261
+ * because S3 does not include it in listing responses).
262
+ *
263
+ * @param api the `OINOBlobApi` whose data model is to be initialised
264
+ */
265
+ async initializeApiDatamodel(api: OINOApi): Promise<void> {
266
+ const blobApi = api as OINOBlobApi
267
+ const datamodel = new OINOBlobDataModel(blobApi)
268
+ const ds = this
269
+ const FIELD: OINODataFieldParams = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false }
270
+ const PK: OINODataFieldParams = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true }
271
+ datamodel.addField(new OINOStringDataField(ds, "name", "TEXT", PK, 1024))
272
+ datamodel.addField(new OINOStringDataField(ds, "etag", "TEXT", FIELD, 256))
273
+ datamodel.addField(new OINODatetimeDataField(ds, "lastModified", "DATETIME", FIELD))
274
+ datamodel.addField(new OINONumberDataField(ds, "contentLength", "INTEGER", FIELD))
275
+ blobApi.initializeDatamodel(datamodel)
276
+ }
277
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { OINOBlobAwsS3 } from "./OINOBlobAwsS3.js"