@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.
- package/dist/cjs/OINOBlob.js +194 -0
- package/dist/cjs/OINOBlobApi.js +180 -0
- package/dist/cjs/OINOBlobConstants.js +7 -0
- package/dist/cjs/OINOBlobDataModel.js +69 -0
- package/dist/cjs/OINOBlobFactory.js +71 -0
- package/dist/cjs/index.js +12 -0
- package/dist/esm/OINOBlob.js +190 -0
- package/dist/esm/OINOBlobApi.js +175 -0
- package/dist/esm/OINOBlobConstants.js +6 -0
- package/dist/esm/OINOBlobDataModel.js +65 -0
- package/dist/esm/OINOBlobFactory.js +67 -0
- package/dist/esm/index.js +4 -0
- package/dist/types/OINOBlob.d.ts +78 -0
- package/dist/types/OINOBlobApi.d.ts +49 -0
- package/dist/types/OINOBlobConstants.d.ts +37 -0
- package/dist/types/OINOBlobDataModel.d.ts +33 -0
- package/dist/types/OINOBlobFactory.d.ts +40 -0
- package/dist/types/index.d.ts +5 -0
- package/package.json +37 -0
- package/src/OINOBlob.ts +238 -0
- package/src/OINOBlobApi.test.ts +393 -0
- package/src/OINOBlobApi.ts +220 -0
- package/src/OINOBlobConstants.ts +47 -0
- package/src/OINOBlobDataModel.ts +66 -0
- package/src/OINOBlobFactory.ts +80 -0
- package/src/index.ts +5 -0
|
@@ -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
|
+
}
|