@oino-ts/blob 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,393 @@
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 { expect, test } from "bun:test"
8
+
9
+ import { OINOBlobAzureTable } from "@oino-ts/blob-azure"
10
+ import { OINOBlobAwsS3 } from "@oino-ts/blob-aws"
11
+ import { OINOQueryFilter, OINOQuerySelect, OINOApiRequest, OINOConsoleLog, OINOLogLevel, OINOLog, OINOBenchmark, OINOContentType } from "@oino-ts/common"
12
+
13
+ import { OINOBlob, OINOBlobApi, OINOBlobApiResult, OINOBlobFactory, type OINOBlobParams } from "./index.js"
14
+
15
+ const OINOCLOUD_TEST_BLOB_AZURE_CONSTR = process.env.OINOCLOUD_TEST_BLOB_AZURE_CONSTR || console.error("OINOCLOUD_TEST_BLOB_AZURE_CONSTR not set") || ""
16
+ const OINOCLOUD_TEST_BLOB_S3_CONSTR = process.env.OINOCLOUD_TEST_BLOB_S3_CONSTR || console.error("OINOCLOUD_TEST_BLOB_S3_CONSTR not set") || ""
17
+
18
+ type OINOBlobStorageParams = {
19
+ /** Connection params passed to OINOBlobFactory.createBlob */
20
+ blobParams: OINOBlobParams
21
+ /** API name exposed via OINOBlobFactory.createApi */
22
+ apiName: string
23
+ /** Blob name prefix / folder used as tableName in the API */
24
+ prefix: string
25
+ }
26
+
27
+ type OINOBlobTestParams = {
28
+ name: string
29
+ /** Filename of an existing blob (no prefix), e.g. "Employees.csv" */
30
+ existingBlobFile: string
31
+ /** Optional filter for the list-with-filter test; undefined means no filter */
32
+ listFilter: OINOQueryFilter | undefined
33
+ /** Filename for the test upload/update/delete blob (no prefix) */
34
+ uploadBlobFile: string
35
+ uploadContent: Uint8Array
36
+ uploadContentType: string
37
+ updateContent: Uint8Array
38
+ responseDownload: string
39
+ }
40
+
41
+ const BLOB_STORAGES: OINOBlobStorageParams[] = [
42
+ {
43
+ blobParams: {
44
+ type: "OINOBlobAzureTable",
45
+ url: "https://oinocloudtest.blob.core.windows.net",
46
+ container: "northwind",
47
+ connectionStr: OINOCLOUD_TEST_BLOB_AZURE_CONSTR
48
+ },
49
+ apiName: "azure-northwind",
50
+ prefix: "northwind-azure/"
51
+ },
52
+ {
53
+ blobParams: {
54
+ type: "OINOBlobAwsS3",
55
+ url: "",
56
+ container: "oinocloud-test-northwind",
57
+ connectionStr: OINOCLOUD_TEST_BLOB_S3_CONSTR
58
+ },
59
+ apiName: "s3-northwind",
60
+ prefix: "northwind-s3/"
61
+ }
62
+ ]
63
+
64
+ const BLOB_TESTS: OINOBlobTestParams[] = [
65
+ {
66
+ name: "BLOB 1",
67
+ existingBlobFile: "Employees.csv",
68
+ listFilter: undefined,
69
+ uploadBlobFile: "oino-test-upload.txt",
70
+ uploadContent: new TextEncoder().encode("Hello from OINOBlobApi test"),
71
+ uploadContentType: "text/plain",
72
+ updateContent: new TextEncoder().encode("Updated content from OINOBlobApi test"),
73
+ responseDownload: "oino-test-download.txt"
74
+ },
75
+ {
76
+ name: "BLOB 2",
77
+ existingBlobFile: "Employees.csv",
78
+ listFilter: OINOQueryFilter.and(OINOQueryFilter.parse("(name)-like(O%)"), OINOQueryFilter.parse("(lastModified)-gt(2020-01-01)")),
79
+ uploadBlobFile: "oino-test-upload.txt",
80
+ uploadContent: new TextEncoder().encode("Hello from OINOBlobApi test"),
81
+ uploadContentType: "text/plain",
82
+ updateContent: new TextEncoder().encode("Updated content from OINOBlobApi test"),
83
+ responseDownload: ""
84
+ },
85
+ {
86
+ name: "BLOB 3",
87
+ existingBlobFile: "Employees.csv",
88
+ listFilter: OINOQueryFilter.and(OINOQueryFilter.parse("(name)-like(O%)"), OINOQueryFilter.parse("(contentLength)-gt(5000)")),
89
+ uploadBlobFile: "oino-test-upload.txt",
90
+ uploadContent: new TextEncoder().encode("Hello from OINOBlobApi test"),
91
+ uploadContentType: "text/plain",
92
+ updateContent: new TextEncoder().encode("Updated content from OINOBlobApi test"),
93
+ responseDownload: ""
94
+ }
95
+ ]
96
+
97
+ /**
98
+ * Fields that are supported by every blob implementation and therefore safe
99
+ * to include in cross-implementation snapshot comparisons.
100
+ */
101
+ const COMMON_BLOB_FIELDS = new OINOQuerySelect(["name", "contentLength"])
102
+
103
+ const BLOB_CROSSCHECKS: string[] = [
104
+ "[LIST ALL] list all: LIST JSON 1",
105
+ "[LIST FILTER] list with filter: LIST FILTERED JSON 1",
106
+ "[HTTP GET] download existing blob: DOWNLOAD BLOB DATA 1",
107
+ "[HTTP GET] download existing blob: DOWNLOAD RESPONSE HEADERS 1",
108
+ "[HTTP GET] download existing blob: DOWNLOAD RESPONSE BODY 1",
109
+ ]
110
+
111
+ OINOLog.setInstance(new OINOConsoleLog(OINOLogLevel.warning))
112
+ OINOBenchmark.setEnabled(["doApiRequest"])
113
+ OINOBenchmark.reset()
114
+
115
+ OINOBlobFactory.registerBlob("OINOBlobAzureTable", OINOBlobAzureTable)
116
+ OINOBlobFactory.registerBlob("OINOBlobAwsS3", OINOBlobAwsS3)
117
+
118
+ function encodeResult(o: unknown): string {
119
+ return JSON.stringify(o ?? {}, null, 3)
120
+ .replaceAll(/`/g, "'")
121
+ .replaceAll(/(\\[nrt"\\]?)/g, (_match, p1) => encodeURIComponent(p1 as string))
122
+ }
123
+
124
+ /** Same as encodeResult but strips volatile storage request IDs and timestamps from error messages */
125
+ function encodeResultStable(o: unknown): string {
126
+ return encodeResult(o)
127
+ .replaceAll(/RequestId:[a-z0-9-]+/g, "RequestId:REQUESTID")
128
+ .replaceAll(/Time:[0-9\-TZ:.]+/g, "Time:TIME")
129
+ }
130
+
131
+ /**
132
+ * Strip volatile fields (etag, lastModified) from a JSON blob listing so that
133
+ * snapshot comparisons are stable across runs.
134
+ */
135
+ function stableBlobListing(json: string | undefined): string {
136
+ if (!json) return ""
137
+ return json
138
+ .replaceAll(/"etag":\s*"[^"]*"/g, '"etag": "ETAG"')
139
+ .replaceAll(/"lastModified":\s*"[^"]*"/g, '"lastModified": "DATE"')
140
+ .replaceAll(/"contentType":\s*"[^"]*"/g, '"contentType": "CONTENT_TYPE"') // S3 does not return content type in listing, but just in case it does in the future
141
+ }
142
+
143
+ export async function OINOTestBlob(storageParams: OINOBlobStorageParams, testParams: OINOBlobTestParams): Promise<void> {
144
+ const target_name = "[" + testParams.name + "]"
145
+ const target_storage = "[" + storageParams.blobParams.type + "]"
146
+
147
+ const existingBlobName = testParams.existingBlobFile
148
+ const uploadBlobName = storageParams.prefix + testParams.uploadBlobFile
149
+
150
+ // ── CONNECTION ────────────────────────────────────────────────────────
151
+
152
+ let target_group = "[CONNECTION]"
153
+
154
+ const wrong_constr_params: OINOBlobParams = { ...storageParams.blobParams, connectionStr: "WRONG_CONNECTION_STRING" }
155
+ const wrong_blob: OINOBlob = await OINOBlobFactory.createBlob(wrong_constr_params, false, false)
156
+ await test(target_name + target_storage + target_group + " connection error", async () => {
157
+ expect(wrong_blob).toBeDefined()
158
+ const connect_res = await wrong_blob.connect()
159
+ // Azure parses the connection string format and throws during connect;
160
+ // S3 only discovers errors at validate time – either way we expect failure.
161
+ const validate_res = connect_res.success ? await wrong_blob.validate() : connect_res
162
+ expect(validate_res.success).toBe(false)
163
+ expect(validate_res.statusText).toMatchSnapshot("CONNECTION ERROR")
164
+ })
165
+
166
+ const blob: OINOBlob = await OINOBlobFactory.createBlob(storageParams.blobParams, false, false)
167
+ await test(target_name + target_storage + target_group + " connection success", async () => {
168
+ expect(blob).toBeDefined()
169
+ const connect_res = await blob.connect()
170
+ expect(connect_res.success).toBe(true)
171
+ const validate_res = await blob.validate()
172
+ expect(validate_res.success).toBe(true)
173
+ expect(blob.isConnected).toBe(true)
174
+ expect(blob.isValidated).toBe(true)
175
+ })
176
+
177
+ const api: OINOBlobApi = await OINOBlobFactory.createApi(blob, {
178
+ apiName: storageParams.apiName,
179
+ tableName: storageParams.prefix
180
+ })
181
+
182
+ const base_url = new URL("http://localhost/" + storageParams.apiName)
183
+
184
+ // ── LIST ALL ──────────────────────────────────────────────────────────
185
+
186
+ target_group = "[LIST ALL]"
187
+
188
+ const list_all_request = new OINOApiRequest({ url: base_url, method: "GET", select: COMMON_BLOB_FIELDS })
189
+ await test(target_name + target_storage + target_group + " list all", async () => {
190
+ const result: OINOBlobApiResult = await api.doApiRequest(list_all_request)
191
+ expect(result.success).toBe(true)
192
+ expect(result.data).toBeDefined()
193
+ const json = await result.data!.writeString(OINOContentType.json)
194
+ expect(stableBlobListing(json)).toMatchSnapshot("LIST JSON")
195
+ })
196
+
197
+ // ── LIST WITH FILTER ──────────────────────────────────────────────────
198
+
199
+ target_group = "[LIST FILTER]"
200
+
201
+ const list_filter_request = new OINOApiRequest({
202
+ url: base_url,
203
+ method: "GET",
204
+ filter: testParams.listFilter,
205
+ select: COMMON_BLOB_FIELDS
206
+ })
207
+ await test(target_name + target_storage + target_group + " list with filter", async () => {
208
+ const result: OINOBlobApiResult = await api.doApiRequest(list_filter_request)
209
+ expect(result.success).toBe(true)
210
+ expect(result.data).toBeDefined()
211
+ const json = await result.data!.writeString(OINOContentType.json)
212
+ expect(stableBlobListing(json)).toMatchSnapshot("LIST FILTERED JSON")
213
+ })
214
+
215
+ // ── DOWNLOAD (GET with id) ────────────────────────────────────────────
216
+
217
+ target_group = "[HTTP GET]"
218
+
219
+ const download_request = new OINOApiRequest({
220
+ url: base_url,
221
+ method: "GET",
222
+ rowId: encodeURIComponent(existingBlobName),
223
+ responseDownload: testParams.responseDownload
224
+ })
225
+ await test(target_name + target_storage + target_group + " download existing blob", async () => {
226
+ const result: OINOBlobApiResult = await api.doApiRequest(download_request)
227
+ expect(result.success).toBe(true)
228
+ expect(result.blobData).toBeDefined()
229
+ expect(result.blobData!.length).toBeGreaterThan(0)
230
+ expect(result.blobDataType).toMatchSnapshot("DOWNLOAD TYPE")
231
+ expect(JSON.stringify(result.blobData, undefined, 0)).toMatchSnapshot("DOWNLOAD BLOB DATA")
232
+ const response = await result.writeApiResponse()
233
+ expect(response.status).toBe(200)
234
+ expect(JSON.stringify(response.headers)).toMatchSnapshot("DOWNLOAD RESPONSE HEADERS")
235
+ expect(await response.text()).toMatchSnapshot("DOWNLOAD RESPONSE BODY")
236
+ })
237
+
238
+ const download_missing_request = new OINOApiRequest({
239
+ url: base_url,
240
+ method: "GET",
241
+ rowId: encodeURIComponent(storageParams.prefix + "oino-does-not-exist.txt")
242
+ })
243
+ await test(target_name + target_storage + target_group + " download missing blob", async () => {
244
+ const result: OINOBlobApiResult = await api.doApiRequest(download_missing_request)
245
+ expect(result.success).toBe(false)
246
+ expect(encodeResultStable(result)).toMatchSnapshot("DOWNLOAD MISSING")
247
+ })
248
+
249
+ // ── INSERT (POST) ─────────────────────────────────────────────────────
250
+
251
+ target_group = "[HTTP POST]"
252
+
253
+ const upload_request = new OINOApiRequest({
254
+ url: base_url,
255
+ method: "POST",
256
+ rowId: encodeURIComponent(uploadBlobName),
257
+ rowData: testParams.uploadContent,
258
+ headers: { "content-type": testParams.uploadContentType }
259
+ })
260
+
261
+ const post_no_id_request = new OINOApiRequest({
262
+ url: base_url,
263
+ method: "POST",
264
+ rowData: testParams.uploadContent,
265
+ headers: { "content-type": testParams.uploadContentType }
266
+ })
267
+
268
+ await test(target_name + target_storage + target_group + " insert without id", async () => {
269
+ const result: OINOBlobApiResult = await api.doApiRequest(post_no_id_request)
270
+ expect(result.success).toBe(false)
271
+ expect(encodeResult(result)).toMatchSnapshot("POST NO ID")
272
+ })
273
+
274
+ await test(target_name + target_storage + target_group + " insert", async () => {
275
+ const result: OINOBlobApiResult = await api.doApiRequest(upload_request)
276
+ expect(result.success).toBe(true)
277
+ expect(encodeResult(result)).toMatchSnapshot("POST")
278
+
279
+ // Verify the blob was actually stored
280
+ const verify_request = new OINOApiRequest({
281
+ url: base_url,
282
+ method: "GET",
283
+ rowId: encodeURIComponent(uploadBlobName)
284
+ })
285
+ const verify_result: OINOBlobApiResult = await api.doApiRequest(verify_request)
286
+ expect(verify_result.success).toBe(true)
287
+ expect(verify_result.blobData).toBeDefined()
288
+ expect(new TextDecoder().decode(verify_result.blobData)).toBe(new TextDecoder().decode(testParams.uploadContent))
289
+ })
290
+
291
+ // ── UPDATE (PUT) ──────────────────────────────────────────────────────
292
+
293
+ target_group = "[HTTP PUT]"
294
+
295
+ const put_no_id_request = new OINOApiRequest({
296
+ url: base_url,
297
+ method: "PUT",
298
+ rowData: testParams.updateContent,
299
+ headers: { "content-type": testParams.uploadContentType }
300
+ })
301
+
302
+ await test(target_name + target_storage + target_group + " update without id", async () => {
303
+ const result: OINOBlobApiResult = await api.doApiRequest(put_no_id_request)
304
+ expect(result.success).toBe(false)
305
+ expect(encodeResult(result)).toMatchSnapshot("PUT NO ID")
306
+ })
307
+
308
+ const update_request = new OINOApiRequest({
309
+ url: base_url,
310
+ method: "PUT",
311
+ rowId: encodeURIComponent(uploadBlobName),
312
+ rowData: testParams.updateContent,
313
+ headers: { "content-type": testParams.uploadContentType }
314
+ })
315
+ await test(target_name + target_storage + target_group + " update", async () => {
316
+ const result: OINOBlobApiResult = await api.doApiRequest(update_request)
317
+ expect(result.success).toBe(true)
318
+ expect(encodeResult(result)).toMatchSnapshot("PUT")
319
+
320
+ // Verify updated content
321
+ const verify_request = new OINOApiRequest({
322
+ url: base_url,
323
+ method: "GET",
324
+ rowId: encodeURIComponent(uploadBlobName)
325
+ })
326
+ const verify_result: OINOBlobApiResult = await api.doApiRequest(verify_request)
327
+ expect(verify_result.success).toBe(true)
328
+ expect(new TextDecoder().decode(verify_result.blobData)).toBe(new TextDecoder().decode(testParams.updateContent))
329
+ })
330
+
331
+ // ── DELETE ────────────────────────────────────────────────────────────
332
+
333
+ target_group = "[HTTP DELETE]"
334
+
335
+ const delete_no_id_request = new OINOApiRequest({ url: base_url, method: "DELETE" })
336
+ await test(target_name + target_storage + target_group + " delete without id", async () => {
337
+ const result: OINOBlobApiResult = await api.doApiRequest(delete_no_id_request)
338
+ expect(result.success).toBe(false)
339
+ expect(encodeResult(result)).toMatchSnapshot("DELETE NO ID")
340
+ })
341
+
342
+ const delete_request = new OINOApiRequest({
343
+ url: base_url,
344
+ method: "DELETE",
345
+ rowId: encodeURIComponent(uploadBlobName)
346
+ })
347
+ await test(target_name + target_storage + target_group + " delete", async () => {
348
+ const result: OINOBlobApiResult = await api.doApiRequest(delete_request)
349
+ expect(result.success).toBe(true)
350
+ expect(encodeResult(result)).toMatchSnapshot("DELETE")
351
+
352
+ // Verify the blob is gone
353
+ const verify_request = new OINOApiRequest({
354
+ url: base_url,
355
+ method: "GET",
356
+ rowId: encodeURIComponent(uploadBlobName)
357
+ })
358
+ const verify_result: OINOBlobApiResult = await api.doApiRequest(verify_request)
359
+ expect(verify_result.success).toBe(false)
360
+ })
361
+ }
362
+
363
+ for (const storage of BLOB_STORAGES) {
364
+ for (const blob_test of BLOB_TESTS) {
365
+ await OINOTestBlob(storage, blob_test)
366
+ }
367
+ }
368
+
369
+ // ── CROSS-CHECK snapshots between adjacent storages ───────────────────────────
370
+
371
+ const snapshot_file = Bun.file("./node_modules/@oino-ts/blob/src/__snapshots__/OINOBlobApi.test.ts.snap")
372
+ const snap_exists = await snapshot_file.exists()
373
+ if (snap_exists) {
374
+ await Bun.write("./node_modules/@oino-ts/blob/src/__snapshots__/OINOBlobApi.test.ts.snap.js", snapshot_file) // copy snapshots as .js so require works (note! if run with --update-snapshots, it's still the old file)
375
+ }
376
+ const snapshots = snap_exists ? require("./__snapshots__/OINOBlobApi.test.ts.snap.js") : {}
377
+
378
+ for (let i = 0; i < BLOB_STORAGES.length - 1; i++) {
379
+ const storage1 = BLOB_STORAGES[i]
380
+ const storage2 = BLOB_STORAGES[i + 1]
381
+ for (const blob_test of BLOB_TESTS) {
382
+ for (const crosscheck of BLOB_CROSSCHECKS) {
383
+ test(
384
+ "cross check {" + storage1.blobParams.type + "} and {" + storage2.blobParams.type + "} test {" + blob_test.name + "} snapshots on {" + crosscheck + "}",
385
+ () => {
386
+ const key1 = "[" + blob_test.name + "][" + storage1.blobParams.type + "]" + crosscheck
387
+ const key2 = "[" + blob_test.name + "][" + storage2.blobParams.type + "]" + crosscheck
388
+ expect(snapshots[key1]).toMatch(snapshots[key2])
389
+ }
390
+ )
391
+ }
392
+ }
393
+ }
@@ -0,0 +1,220 @@
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
+ OINOApi,
9
+ OINOApiParams,
10
+ OINOApiRequest,
11
+ OINOApiResult,
12
+ OINOModelSet,
13
+ OINOContentType,
14
+ OINOQueryParams,
15
+ OINOHttpRequest,
16
+ type OINOApiData,
17
+ OINOLog,
18
+ OINO_ERROR_PREFIX
19
+ } from "@oino-ts/common"
20
+ import { OINOBlob } from "./OINOBlob.js"
21
+ import { OINOBlobDataModel } from "./OINOBlobDataModel.js"
22
+
23
+
24
+ export class OINOBlobApiResult extends OINOApiResult {
25
+ /** Binary content of the blob (for GET with id) */
26
+ blobData?: Uint8Array
27
+ /** Content-Type of the blob (for GET with id) */
28
+ blobDataType?: string
29
+
30
+ constructor(request: OINOApiRequest, data?: OINOModelSet, blobData?: Uint8Array, blobDataType?: string) {
31
+ super(request, data)
32
+ this.blobData = blobData
33
+ this.blobDataType = blobDataType
34
+ }
35
+
36
+ override async writeApiResponse(headers: Record<string, string> = {}): Promise<Response> {
37
+ if (this.blobData) {
38
+ const response_headers = new Headers(headers)
39
+ response_headers.set("Content-Length", this.blobData.length.toString())
40
+ if (this.blobDataType) {
41
+ response_headers.set("Content-Type", this.blobDataType)
42
+ }
43
+ if (this.request.responseDownload) {
44
+ response_headers.set("Content-Disposition", `attachment; filename="${this.request.responseDownload}"`)
45
+ } else {
46
+ response_headers.set("Content-Disposition", "inline")
47
+ }
48
+ return new Response(this.blobData, {
49
+ status: this.status,
50
+ statusText: this.statusText,
51
+ headers: response_headers
52
+ })
53
+ } else {
54
+ return super.writeApiResponse(headers)
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * REST API for blob storage.
61
+ *
62
+ * Supports two GET variants:
63
+ * - **GET without id** – lists all blobs under the configured prefix and
64
+ * returns the metadata as JSON (or CSV) using `OINOModelSet`.
65
+ * - **GET with id** – downloads the named blob as a binary HTTP response
66
+ * with the blob's own `Content-Type`.
67
+ *
68
+ * All other HTTP methods return `405 Method Not Allowed`.
69
+ */
70
+ export class OINOBlobApi extends OINOApi {
71
+
72
+ /** Blob storage backend */
73
+ readonly blob: OINOBlob
74
+
75
+ /** Blob-specific data model (populated by `initializeDatamodel`) */
76
+ blobDatamodel: OINOBlobDataModel | null = null
77
+
78
+ /**
79
+ * Constructor.
80
+ *
81
+ * NOTE: `initializeDatamodel` (or `OINOBlobFactory.createApi`) must be
82
+ * called before the first request is dispatched.
83
+ *
84
+ * @param blob blob storage backend
85
+ * @param params API parameters (`tableName` is used as the blob prefix)
86
+ */
87
+ constructor(blob: OINOBlob, params: OINOApiParams) {
88
+ super(blob, params)
89
+ this.blob = blob
90
+ }
91
+
92
+ /**
93
+ * Attach the static blob data model and mark the API as initialised.
94
+ *
95
+ * @param datamodel `OINOBlobDataModel` instance for this API
96
+ */
97
+ initializeDatamodel(datamodel: OINOBlobDataModel): void {
98
+ this.blobDatamodel = datamodel
99
+ this.datamodel = datamodel
100
+ this.initialized = true
101
+ }
102
+
103
+ // ── OINOApi abstract implementations ─────────────────────────────────
104
+
105
+ async doApiRequest(request: OINOApiRequest): Promise<OINOBlobApiResult> {
106
+ if (!this.initialized) {
107
+ throw new Error(OINO_ERROR_PREFIX + ": OINOBlobApi is not initialized yet!")
108
+ }
109
+ OINOLog.debug("@oino-ts/blob", "OINOBlobApi", "doApiRequest", "Request",
110
+ { method: request.method, id: request.rowId })
111
+
112
+ const result = new OINOBlobApiResult(request)
113
+
114
+ if (request.method === "GET") {
115
+ if (!request.rowId) {
116
+ // ── List blobs ───────────────────────────────────────────────
117
+ try {
118
+ const entries = await this.blob.listEntries(request.queryParams?.filter)
119
+ const dataset = this.blobDatamodel!.entriesToDataset(entries)
120
+ result.data = new OINOModelSet(this.datamodel!, dataset, request.queryParams)
121
+ } catch (e: any) {
122
+ result.setError(500, "Error listing blobs: " + e.message, "DoGet")
123
+ OINOLog.exception("@oino-ts/blob", "OINOBlobApi", "doApiRequest",
124
+ "exception in list request", { message: e.message, stack: e.stack })
125
+ }
126
+ } else {
127
+ // ── Download blob ────────────────────────────────────────────
128
+ try {
129
+ const name = decodeURIComponent(request.rowId)
130
+ const fetch_result = await this.blob.fetchEntry(name)
131
+ result.blobData = fetch_result.content
132
+ result.blobDataType = fetch_result.contentType
133
+ } catch (e: any) {
134
+ result.setError(500, "Error fetching blob: " + e.message, "DoGet")
135
+ OINOLog.exception("@oino-ts/blob", "OINOBlobApi", "doApiRequest",
136
+ "exception in fetch request", { message: e.message, stack: e.stack })
137
+ }
138
+ }
139
+
140
+ } else if (request.method === "POST" || request.method === "PUT") {
141
+ if (!request.rowId) {
142
+ result.setError(400, "HTTP " + request.method + " method requires an URL ID (blob name)!", "DoRequest")
143
+ } else {
144
+ try {
145
+ const name = decodeURIComponent(request.rowId)
146
+ const content_type = request.headers.get("content-type") ?? "application/octet-stream"
147
+ const data = request.rowData
148
+ const content: Uint8Array = data instanceof Uint8Array ? data : request.bodyAsBuffer()
149
+ await this.blob.uploadEntry(name, content, content_type)
150
+ } catch (e: any) {
151
+ result.setError(500, "Error uploading blob: " + e.message, "DoPost")
152
+ OINOLog.exception("@oino-ts/blob", "OINOBlobApi", "doApiRequest",
153
+ "exception in upload request", { message: e.message, stack: e.stack })
154
+ }
155
+ }
156
+
157
+ } else if (request.method === "DELETE") {
158
+ if (!request.rowId) {
159
+ result.setError(400, "HTTP DELETE method requires an URL ID (blob name)!", "DoRequest")
160
+ } else {
161
+ try {
162
+ const name = decodeURIComponent(request.rowId)
163
+ await this.blob.deleteEntry(name)
164
+ } catch (e: any) {
165
+ result.setError(500, "Error deleting blob: " + e.message, "DoDelete")
166
+ OINOLog.exception("@oino-ts/blob", "OINOBlobApi", "doApiRequest",
167
+ "exception in delete request", { message: e.message, stack: e.stack })
168
+ }
169
+ }
170
+
171
+ } else {
172
+ result.setError(405, "Unsupported HTTP method '" + request.method + "' for OINOBlobApi", "DoRequest")
173
+ }
174
+
175
+ return result
176
+ }
177
+
178
+ async doHttpRequest(
179
+ request: OINOHttpRequest,
180
+ rowId: string,
181
+ rowData: OINOApiData,
182
+ queryParams: OINOQueryParams
183
+ ): Promise<OINOBlobApiResult> {
184
+ const api_request = OINOApiRequest.fromHttpRequest(request, rowId, rowData, queryParams)
185
+ return this.doApiRequest(api_request)
186
+ }
187
+
188
+ async doRequest(
189
+ method: string,
190
+ rowId: string,
191
+ rowData: OINOApiData,
192
+ queryParams: OINOQueryParams,
193
+ contentType: OINOContentType = OINOContentType.json
194
+ ): Promise<OINOBlobApiResult> {
195
+ return this.doApiRequest(new OINOApiRequest({
196
+ method,
197
+ rowId,
198
+ rowData,
199
+ queryParams,
200
+ requestType: contentType
201
+ }))
202
+ }
203
+
204
+ async doBatchUpdate(
205
+ method: string,
206
+ _rowId: string,
207
+ _rowData: OINOApiData,
208
+ _queryParams?: OINOQueryParams
209
+ ): Promise<OINOBlobApiResult> {
210
+ const result = new OINOApiResult(new OINOApiRequest({ method }))
211
+ result.setError(405, "OINOBlobApi does not support batch updates", "DoBatchUpdate")
212
+ return result
213
+ }
214
+
215
+ async doBatchApiRequest(request: OINOApiRequest): Promise<OINOBlobApiResult> {
216
+ const result = new OINOApiResult(request)
217
+ result.setError(405, "OINOBlobApi does not support batch updates", "DoBatchApiRequest")
218
+ return result
219
+ }
220
+ }
@@ -0,0 +1,47 @@
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 type { OINOBlob } from "./OINOBlob.js"
8
+
9
+ /**
10
+ * Blob class (constructor) type
11
+ * @param params blob parameters
12
+ */
13
+ export type OINOBlobConstructor = new (params: OINOBlobParams) => OINOBlob
14
+
15
+ /** Blob storage connection parameters */
16
+ export type OINOBlobParams = {
17
+ /** Name of the blob class (e.g. OINOBlobAzureTable) */
18
+ type: string
19
+ /** Blob service endpoint URL */
20
+ url: string
21
+ /** Container / bucket name */
22
+ container: string
23
+ /** Provider-specific connection string (e.g. Azure Storage connection string or SAS URL) */
24
+ connectionStr?: string
25
+ }
26
+
27
+ /** A single blob entry returned by a listing operation */
28
+ export type OINOBlobEntry = {
29
+ /** Full blob name (path within the container) */
30
+ name: string
31
+ /** Entity tag */
32
+ etag: string
33
+ /** Last modification timestamp */
34
+ lastModified: Date
35
+ /** Size in bytes */
36
+ contentLength: number
37
+ /** MIME content type */
38
+ contentType: string
39
+ }
40
+
41
+ /** Result of a blob fetch operation */
42
+ export type OINOBlobFetchResult = {
43
+ /** Raw blob bytes */
44
+ content: Uint8Array
45
+ /** MIME content type of the blob */
46
+ contentType: string
47
+ }