@oino-ts/blob-azure 1.3.1 → 1.5.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/OINOBlobAzure.js +30 -0
- package/dist/esm/OINOBlobAzure.js +30 -0
- package/dist/types/OINOBlobAzure.d.ts +10 -0
- package/package.json +4 -4
- package/src/OINOBlobAzure.ts +273 -243
|
@@ -184,6 +184,36 @@ class OINOBlobAzure extends blob_1.OINOBlob {
|
|
|
184
184
|
const blockBlobClient = this._containerClient.getBlockBlobClient(name);
|
|
185
185
|
await blockBlobClient.upload(content, content.length, { blobHTTPHeaders: { blobContentType: contentType } });
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Create a blob only if one does not already exist (atomic claim).
|
|
189
|
+
* Returns true if this call created the blob, false if it already existed.
|
|
190
|
+
* Never overwrites — unlike uploadEntry. Real I/O errors are rethrown.
|
|
191
|
+
*
|
|
192
|
+
* @param name full blob name (path within the container)
|
|
193
|
+
* @param content binary content to store
|
|
194
|
+
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
195
|
+
*/
|
|
196
|
+
async uploadEntryIfAbsent(name, content, contentType) {
|
|
197
|
+
if (!this._containerClient) {
|
|
198
|
+
throw new Error("OINOBlobAzure: not connected");
|
|
199
|
+
}
|
|
200
|
+
const blockBlobClient = this._containerClient.getBlockBlobClient(name);
|
|
201
|
+
try {
|
|
202
|
+
await blockBlobClient.upload(content, content.length, {
|
|
203
|
+
blobHTTPHeaders: { blobContentType: contentType },
|
|
204
|
+
conditions: { ifNoneMatch: "*" } // succeed only if the blob does not exist
|
|
205
|
+
});
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
// Blob already existed -> we lost the claim. Azure returns 409 (BlobAlreadyExists);
|
|
210
|
+
// 412 guards against SDK/version variance. Anything else is a real failure -> rethrow.
|
|
211
|
+
if (e && (e.statusCode === 409 || e.statusCode === 412)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
187
217
|
/**
|
|
188
218
|
* Delete a named blob.
|
|
189
219
|
*
|
|
@@ -181,6 +181,36 @@ export class OINOBlobAzure extends OINOBlob {
|
|
|
181
181
|
const blockBlobClient = this._containerClient.getBlockBlobClient(name);
|
|
182
182
|
await blockBlobClient.upload(content, content.length, { blobHTTPHeaders: { blobContentType: contentType } });
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Create a blob only if one does not already exist (atomic claim).
|
|
186
|
+
* Returns true if this call created the blob, false if it already existed.
|
|
187
|
+
* Never overwrites — unlike uploadEntry. Real I/O errors are rethrown.
|
|
188
|
+
*
|
|
189
|
+
* @param name full blob name (path within the container)
|
|
190
|
+
* @param content binary content to store
|
|
191
|
+
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
192
|
+
*/
|
|
193
|
+
async uploadEntryIfAbsent(name, content, contentType) {
|
|
194
|
+
if (!this._containerClient) {
|
|
195
|
+
throw new Error("OINOBlobAzure: not connected");
|
|
196
|
+
}
|
|
197
|
+
const blockBlobClient = this._containerClient.getBlockBlobClient(name);
|
|
198
|
+
try {
|
|
199
|
+
await blockBlobClient.upload(content, content.length, {
|
|
200
|
+
blobHTTPHeaders: { blobContentType: contentType },
|
|
201
|
+
conditions: { ifNoneMatch: "*" } // succeed only if the blob does not exist
|
|
202
|
+
});
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
// Blob already existed -> we lost the claim. Azure returns 409 (BlobAlreadyExists);
|
|
207
|
+
// 412 guards against SDK/version variance. Anything else is a real failure -> rethrow.
|
|
208
|
+
if (e && (e.statusCode === 409 || e.statusCode === 412)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
184
214
|
/**
|
|
185
215
|
* Delete a named blob.
|
|
186
216
|
*
|
|
@@ -48,6 +48,16 @@ export declare class OINOBlobAzure extends OINOBlob {
|
|
|
48
48
|
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
49
49
|
*/
|
|
50
50
|
uploadEntry(name: string, content: Uint8Array, contentType: string): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Create a blob only if one does not already exist (atomic claim).
|
|
53
|
+
* Returns true if this call created the blob, false if it already existed.
|
|
54
|
+
* Never overwrites — unlike uploadEntry. Real I/O errors are rethrown.
|
|
55
|
+
*
|
|
56
|
+
* @param name full blob name (path within the container)
|
|
57
|
+
* @param content binary content to store
|
|
58
|
+
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
59
|
+
*/
|
|
60
|
+
uploadEntryIfAbsent(name: string, content: Uint8Array, contentType: string): Promise<boolean>;
|
|
51
61
|
/**
|
|
52
62
|
* Delete a named blob.
|
|
53
63
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oino-ts/blob-azure",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "OINO TS package for using Azure Blob Storage as a REST API.",
|
|
5
5
|
"author": "Matias Kiviniemi (pragmatta)",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@azure/storage-blob": "^12.0.0",
|
|
24
24
|
"@azure/identity": "^3.0.0",
|
|
25
|
-
"@oino-ts/blob": "1.
|
|
26
|
-
"@oino-ts/common": "1.
|
|
25
|
+
"@oino-ts/blob": "1.5.0",
|
|
26
|
+
"@oino-ts/common": "1.5.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@oino-ts/types": "1.
|
|
29
|
+
"@oino-ts/types": "1.5.0",
|
|
30
30
|
"@types/bun": "^1.3.14",
|
|
31
31
|
"@types/node": "^22.0.00",
|
|
32
32
|
"typescript": "~5.9.0"
|
package/src/OINOBlobAzure.ts
CHANGED
|
@@ -1,243 +1,273 @@
|
|
|
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 { Buffer } from "node:buffer"
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
BlobServiceClient,
|
|
11
|
-
ContainerClient
|
|
12
|
-
} from "@azure/storage-blob"
|
|
13
|
-
import { DefaultAzureCredential } from "@azure/identity"
|
|
14
|
-
|
|
15
|
-
import { OINOLog } from "@oino-ts/common"
|
|
16
|
-
import { OINOApi, OINOResult, OINOQueryFilter, OINOStringDataField, OINONumberDataField, OINODatetimeDataField, type OINODataFieldParams } from "@oino-ts/common"
|
|
17
|
-
import { OINOBlob, OINOBlobParams, OINOBlobDataModel, OINOBlobApi, type OINOBlobEntry, type OINOBlobFetchResult } from "@oino-ts/blob"
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Azure Blob Storage implementation of `OINOBlob`.
|
|
21
|
-
*
|
|
22
|
-
* Authenticates using an Azure Storage connection string. Connection parameters map as:
|
|
23
|
-
* - `params.url` → blob service endpoint, e.g. `https://<account>.blob.core.windows.net`
|
|
24
|
-
* - `params.container` → container name
|
|
25
|
-
* - `params.connectionStr` → Azure Storage connection string (e.g. `DefaultEndpointsProtocol=https;AccountName=...`)
|
|
26
|
-
*
|
|
27
|
-
* Register and use via the factory:
|
|
28
|
-
* ```ts
|
|
29
|
-
* import { OINOBlobFactory } from "@oino-ts/blob"
|
|
30
|
-
* import { OINOBlobAzure } from "@oino-ts/blob-azure"
|
|
31
|
-
*
|
|
32
|
-
* OINOBlobFactory.registerBlob("OINOBlobAzure", OINOBlobAzure)
|
|
33
|
-
*
|
|
34
|
-
* const blob = await OINOBlobFactory.createBlob({
|
|
35
|
-
* type: "OINOBlobAzure",
|
|
36
|
-
* container: "my-container",
|
|
37
|
-
* credentials: either connectionStr or url and clientId
|
|
38
|
-
* })
|
|
39
|
-
* const api = await OINOBlobFactory.createApi(blob, {
|
|
40
|
-
* apiName: "files",
|
|
41
|
-
* tableName: "uploads/" // blob prefix / folder
|
|
42
|
-
* })
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
|
-
const BLOB_AZURE_ILLEGAL_CHARS_REGEX = /[\x00-\x1f\x7f\\]/g
|
|
46
|
-
|
|
47
|
-
export class OINOBlobAzure extends OINOBlob {
|
|
48
|
-
private _containerClient: ContainerClient | null = null
|
|
49
|
-
|
|
50
|
-
constructor(params: OINOBlobParams) {
|
|
51
|
-
super(params)
|
|
52
|
-
if ((!this.blobParams.credentials?.connectionStr) && !(this.blobParams.credentials?.url)) {
|
|
53
|
-
throw new Error("OINOBlobAzure: missing or invalid credentials (provide either connectionStr or url and clientId)")
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Replace characters that Azure Blob Storage does not permit in blob names
|
|
59
|
-
* (`\` and ASCII control characters) with `_`.
|
|
60
|
-
*/
|
|
61
|
-
override sanitizeName(name: string): string {
|
|
62
|
-
return name.replace(BLOB_AZURE_ILLEGAL_CHARS_REGEX, "_")
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Initialise the Azure SDK client. Does not perform any network call.
|
|
67
|
-
*/
|
|
68
|
-
async connect(): Promise<OINOResult> {
|
|
69
|
-
const result = new OINOResult()
|
|
70
|
-
let serviceClient: BlobServiceClient
|
|
71
|
-
try {
|
|
72
|
-
if (this.blobParams.credentials?.connectionStr) {
|
|
73
|
-
serviceClient = BlobServiceClient.fromConnectionString(this.blobParams.credentials.connectionStr)
|
|
74
|
-
|
|
75
|
-
} else if (this.blobParams.credentials?.url) { // && this.blobParams.credentials?.clientId) {
|
|
76
|
-
// Use ContainerClient directly to avoid double-container path when combining service URL + container
|
|
77
|
-
serviceClient = new BlobServiceClient(
|
|
78
|
-
this.blobParams.credentials.url,
|
|
79
|
-
new DefaultAzureCredential({ managedIdentityClientId: this.blobParams.credentials.clientId })
|
|
80
|
-
)
|
|
81
|
-
this.isConnected = true
|
|
82
|
-
}
|
|
83
|
-
this._containerClient = serviceClient!.getContainerClient(this.blobParams.container)
|
|
84
|
-
this.isConnected = true
|
|
85
|
-
|
|
86
|
-
} catch (e: any) {
|
|
87
|
-
result.setError(500, "OINOBlobAzure connect failed: " + e.message, "connect")
|
|
88
|
-
OINOLog.exception("@oino-ts/blob-azure", "OINOBlobAzure", "connect", "OINOBlobAzure connect failed", { error: e, stack: e.stack })
|
|
89
|
-
}
|
|
90
|
-
return result
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Verify that the target container exists and is accessible.
|
|
95
|
-
*/
|
|
96
|
-
async validate(): Promise<OINOResult> {
|
|
97
|
-
if (!this._containerClient) {
|
|
98
|
-
return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAzure: not connected" })
|
|
99
|
-
}
|
|
100
|
-
try {
|
|
101
|
-
const exists = await this._containerClient.exists()
|
|
102
|
-
if (!exists) {
|
|
103
|
-
return new OINOResult({
|
|
104
|
-
success: false,
|
|
105
|
-
status: 404,
|
|
106
|
-
statusText: "OINOBlobAzure: container '" + this.blobParams.container + "' not found"
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
this.isValidated = true
|
|
110
|
-
} catch (e: any) {
|
|
111
|
-
return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAzure validate failed: " + e.message })
|
|
112
|
-
}
|
|
113
|
-
return new OINOResult()
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Release the client reference (Azure SDK is stateless per-request so nothing to close).
|
|
118
|
-
*/
|
|
119
|
-
async disconnect(): Promise<void> {
|
|
120
|
-
this._containerClient = null
|
|
121
|
-
this.isConnected = false
|
|
122
|
-
this.isValidated = false
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── OINOBlob operations ───────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* List all blobs, applying native Azure query filtering where possible and
|
|
129
|
-
* in-memory result filtering for predicates that cannot be expressed as a
|
|
130
|
-
* native query.
|
|
131
|
-
*
|
|
132
|
-
* - The `name` field supports server-side prefix filtering via the Azure
|
|
133
|
-
* `listBlobsFlat` `prefix` option (query filtering).
|
|
134
|
-
* - All other field predicates (`etag`, `lastModified`, `contentLength`,
|
|
135
|
-
* `contentType`) are evaluated in-memory after the listing (result
|
|
136
|
-
* filtering).
|
|
137
|
-
*
|
|
138
|
-
* @param filter optional query filter to apply
|
|
139
|
-
*/
|
|
140
|
-
async listEntries(filter?: OINOQueryFilter): Promise<OINOBlobEntry[]> {
|
|
141
|
-
if (!this._containerClient) {
|
|
142
|
-
throw new Error("OINOBlobAzure: not connected")
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const queryPrefix = (filter && !filter.isEmpty())
|
|
146
|
-
? OINOBlob.extractNamePrefix(filter)
|
|
147
|
-
: undefined
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const entries: OINOBlobEntry[] = []
|
|
151
|
-
for await (const blob of this._containerClient.listBlobsFlat({ prefix: queryPrefix })) {
|
|
152
|
-
entries.push({
|
|
153
|
-
name: blob.name,
|
|
154
|
-
etag: blob.properties.etag ?? "",
|
|
155
|
-
lastModified: blob.properties.lastModified,
|
|
156
|
-
contentLength: blob.properties.contentLength ?? 0,
|
|
157
|
-
contentType: blob.properties.contentType ?? "application/octet-stream"
|
|
158
|
-
})
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (!filter || filter.isEmpty()) {
|
|
162
|
-
return entries
|
|
163
|
-
}
|
|
164
|
-
return entries.filter(e => OINOBlob.matchesEntry(e, filter))
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Download the raw content of a named blob.
|
|
170
|
-
*
|
|
171
|
-
* @param name full blob name (path within the container)
|
|
172
|
-
*/
|
|
173
|
-
async fetchEntry(name: string): Promise<OINOBlobFetchResult> {
|
|
174
|
-
if (!this._containerClient) {
|
|
175
|
-
throw new Error("OINOBlobAzure: not connected")
|
|
176
|
-
}
|
|
177
|
-
const blobClient = this._containerClient.getBlobClient(name)
|
|
178
|
-
const downloadResponse = await blobClient.download(0)
|
|
179
|
-
const contentType = downloadResponse.contentType ?? "application/octet-stream"
|
|
180
|
-
const stream = downloadResponse.readableStreamBody
|
|
181
|
-
if (!stream) {
|
|
182
|
-
throw new Error("OINOBlobAzure: no readable stream returned for blob '" + name + "'")
|
|
183
|
-
}
|
|
184
|
-
const chunks: Buffer[] = []
|
|
185
|
-
for await (const chunk of stream) {
|
|
186
|
-
chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk as Uint8Array|string))
|
|
187
|
-
}
|
|
188
|
-
return {
|
|
189
|
-
content: new Uint8Array(Buffer.concat(chunks)),
|
|
190
|
-
contentType
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Upload (create or replace) a blob with the given binary content.
|
|
196
|
-
*
|
|
197
|
-
* @param name full blob name (path within the container)
|
|
198
|
-
* @param content binary content to store
|
|
199
|
-
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
200
|
-
*/
|
|
201
|
-
async uploadEntry(name: string, content: Uint8Array, contentType: string): Promise<void> {
|
|
202
|
-
if (!this._containerClient) {
|
|
203
|
-
throw new Error("OINOBlobAzure: not connected")
|
|
204
|
-
}
|
|
205
|
-
const blockBlobClient = this._containerClient.getBlockBlobClient(name)
|
|
206
|
-
await blockBlobClient.upload(content, content.length, { blobHTTPHeaders: { blobContentType: contentType } })
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 { Buffer } from "node:buffer"
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
BlobServiceClient,
|
|
11
|
+
ContainerClient
|
|
12
|
+
} from "@azure/storage-blob"
|
|
13
|
+
import { DefaultAzureCredential } from "@azure/identity"
|
|
14
|
+
|
|
15
|
+
import { OINOLog } from "@oino-ts/common"
|
|
16
|
+
import { OINOApi, OINOResult, OINOQueryFilter, OINOStringDataField, OINONumberDataField, OINODatetimeDataField, type OINODataFieldParams } from "@oino-ts/common"
|
|
17
|
+
import { OINOBlob, OINOBlobParams, OINOBlobDataModel, OINOBlobApi, type OINOBlobEntry, type OINOBlobFetchResult } from "@oino-ts/blob"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Azure Blob Storage implementation of `OINOBlob`.
|
|
21
|
+
*
|
|
22
|
+
* Authenticates using an Azure Storage connection string. Connection parameters map as:
|
|
23
|
+
* - `params.url` → blob service endpoint, e.g. `https://<account>.blob.core.windows.net`
|
|
24
|
+
* - `params.container` → container name
|
|
25
|
+
* - `params.connectionStr` → Azure Storage connection string (e.g. `DefaultEndpointsProtocol=https;AccountName=...`)
|
|
26
|
+
*
|
|
27
|
+
* Register and use via the factory:
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { OINOBlobFactory } from "@oino-ts/blob"
|
|
30
|
+
* import { OINOBlobAzure } from "@oino-ts/blob-azure"
|
|
31
|
+
*
|
|
32
|
+
* OINOBlobFactory.registerBlob("OINOBlobAzure", OINOBlobAzure)
|
|
33
|
+
*
|
|
34
|
+
* const blob = await OINOBlobFactory.createBlob({
|
|
35
|
+
* type: "OINOBlobAzure",
|
|
36
|
+
* container: "my-container",
|
|
37
|
+
* credentials: either connectionStr or url and clientId
|
|
38
|
+
* })
|
|
39
|
+
* const api = await OINOBlobFactory.createApi(blob, {
|
|
40
|
+
* apiName: "files",
|
|
41
|
+
* tableName: "uploads/" // blob prefix / folder
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
const BLOB_AZURE_ILLEGAL_CHARS_REGEX = /[\x00-\x1f\x7f\\]/g
|
|
46
|
+
|
|
47
|
+
export class OINOBlobAzure extends OINOBlob {
|
|
48
|
+
private _containerClient: ContainerClient | null = null
|
|
49
|
+
|
|
50
|
+
constructor(params: OINOBlobParams) {
|
|
51
|
+
super(params)
|
|
52
|
+
if ((!this.blobParams.credentials?.connectionStr) && !(this.blobParams.credentials?.url)) {
|
|
53
|
+
throw new Error("OINOBlobAzure: missing or invalid credentials (provide either connectionStr or url and clientId)")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Replace characters that Azure Blob Storage does not permit in blob names
|
|
59
|
+
* (`\` and ASCII control characters) with `_`.
|
|
60
|
+
*/
|
|
61
|
+
override sanitizeName(name: string): string {
|
|
62
|
+
return name.replace(BLOB_AZURE_ILLEGAL_CHARS_REGEX, "_")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Initialise the Azure SDK client. Does not perform any network call.
|
|
67
|
+
*/
|
|
68
|
+
async connect(): Promise<OINOResult> {
|
|
69
|
+
const result = new OINOResult()
|
|
70
|
+
let serviceClient: BlobServiceClient
|
|
71
|
+
try {
|
|
72
|
+
if (this.blobParams.credentials?.connectionStr) {
|
|
73
|
+
serviceClient = BlobServiceClient.fromConnectionString(this.blobParams.credentials.connectionStr)
|
|
74
|
+
|
|
75
|
+
} else if (this.blobParams.credentials?.url) { // && this.blobParams.credentials?.clientId) {
|
|
76
|
+
// Use ContainerClient directly to avoid double-container path when combining service URL + container
|
|
77
|
+
serviceClient = new BlobServiceClient(
|
|
78
|
+
this.blobParams.credentials.url,
|
|
79
|
+
new DefaultAzureCredential({ managedIdentityClientId: this.blobParams.credentials.clientId })
|
|
80
|
+
)
|
|
81
|
+
this.isConnected = true
|
|
82
|
+
}
|
|
83
|
+
this._containerClient = serviceClient!.getContainerClient(this.blobParams.container)
|
|
84
|
+
this.isConnected = true
|
|
85
|
+
|
|
86
|
+
} catch (e: any) {
|
|
87
|
+
result.setError(500, "OINOBlobAzure connect failed: " + e.message, "connect")
|
|
88
|
+
OINOLog.exception("@oino-ts/blob-azure", "OINOBlobAzure", "connect", "OINOBlobAzure connect failed", { error: e, stack: e.stack })
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verify that the target container exists and is accessible.
|
|
95
|
+
*/
|
|
96
|
+
async validate(): Promise<OINOResult> {
|
|
97
|
+
if (!this._containerClient) {
|
|
98
|
+
return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAzure: not connected" })
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const exists = await this._containerClient.exists()
|
|
102
|
+
if (!exists) {
|
|
103
|
+
return new OINOResult({
|
|
104
|
+
success: false,
|
|
105
|
+
status: 404,
|
|
106
|
+
statusText: "OINOBlobAzure: container '" + this.blobParams.container + "' not found"
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
this.isValidated = true
|
|
110
|
+
} catch (e: any) {
|
|
111
|
+
return new OINOResult({ success: false, status: 500, statusText: "OINOBlobAzure validate failed: " + e.message })
|
|
112
|
+
}
|
|
113
|
+
return new OINOResult()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Release the client reference (Azure SDK is stateless per-request so nothing to close).
|
|
118
|
+
*/
|
|
119
|
+
async disconnect(): Promise<void> {
|
|
120
|
+
this._containerClient = null
|
|
121
|
+
this.isConnected = false
|
|
122
|
+
this.isValidated = false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── OINOBlob operations ───────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* List all blobs, applying native Azure query filtering where possible and
|
|
129
|
+
* in-memory result filtering for predicates that cannot be expressed as a
|
|
130
|
+
* native query.
|
|
131
|
+
*
|
|
132
|
+
* - The `name` field supports server-side prefix filtering via the Azure
|
|
133
|
+
* `listBlobsFlat` `prefix` option (query filtering).
|
|
134
|
+
* - All other field predicates (`etag`, `lastModified`, `contentLength`,
|
|
135
|
+
* `contentType`) are evaluated in-memory after the listing (result
|
|
136
|
+
* filtering).
|
|
137
|
+
*
|
|
138
|
+
* @param filter optional query filter to apply
|
|
139
|
+
*/
|
|
140
|
+
async listEntries(filter?: OINOQueryFilter): Promise<OINOBlobEntry[]> {
|
|
141
|
+
if (!this._containerClient) {
|
|
142
|
+
throw new Error("OINOBlobAzure: not connected")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const queryPrefix = (filter && !filter.isEmpty())
|
|
146
|
+
? OINOBlob.extractNamePrefix(filter)
|
|
147
|
+
: undefined
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
const entries: OINOBlobEntry[] = []
|
|
151
|
+
for await (const blob of this._containerClient.listBlobsFlat({ prefix: queryPrefix })) {
|
|
152
|
+
entries.push({
|
|
153
|
+
name: blob.name,
|
|
154
|
+
etag: blob.properties.etag ?? "",
|
|
155
|
+
lastModified: blob.properties.lastModified,
|
|
156
|
+
contentLength: blob.properties.contentLength ?? 0,
|
|
157
|
+
contentType: blob.properties.contentType ?? "application/octet-stream"
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!filter || filter.isEmpty()) {
|
|
162
|
+
return entries
|
|
163
|
+
}
|
|
164
|
+
return entries.filter(e => OINOBlob.matchesEntry(e, filter))
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Download the raw content of a named blob.
|
|
170
|
+
*
|
|
171
|
+
* @param name full blob name (path within the container)
|
|
172
|
+
*/
|
|
173
|
+
async fetchEntry(name: string): Promise<OINOBlobFetchResult> {
|
|
174
|
+
if (!this._containerClient) {
|
|
175
|
+
throw new Error("OINOBlobAzure: not connected")
|
|
176
|
+
}
|
|
177
|
+
const blobClient = this._containerClient.getBlobClient(name)
|
|
178
|
+
const downloadResponse = await blobClient.download(0)
|
|
179
|
+
const contentType = downloadResponse.contentType ?? "application/octet-stream"
|
|
180
|
+
const stream = downloadResponse.readableStreamBody
|
|
181
|
+
if (!stream) {
|
|
182
|
+
throw new Error("OINOBlobAzure: no readable stream returned for blob '" + name + "'")
|
|
183
|
+
}
|
|
184
|
+
const chunks: Buffer[] = []
|
|
185
|
+
for await (const chunk of stream) {
|
|
186
|
+
chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk as Uint8Array|string))
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
content: new Uint8Array(Buffer.concat(chunks)),
|
|
190
|
+
contentType
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Upload (create or replace) a blob with the given binary content.
|
|
196
|
+
*
|
|
197
|
+
* @param name full blob name (path within the container)
|
|
198
|
+
* @param content binary content to store
|
|
199
|
+
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
200
|
+
*/
|
|
201
|
+
async uploadEntry(name: string, content: Uint8Array, contentType: string): Promise<void> {
|
|
202
|
+
if (!this._containerClient) {
|
|
203
|
+
throw new Error("OINOBlobAzure: not connected")
|
|
204
|
+
}
|
|
205
|
+
const blockBlobClient = this._containerClient.getBlockBlobClient(name)
|
|
206
|
+
await blockBlobClient.upload(content, content.length, { blobHTTPHeaders: { blobContentType: contentType } })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create a blob only if one does not already exist (atomic claim).
|
|
211
|
+
* Returns true if this call created the blob, false if it already existed.
|
|
212
|
+
* Never overwrites — unlike uploadEntry. Real I/O errors are rethrown.
|
|
213
|
+
*
|
|
214
|
+
* @param name full blob name (path within the container)
|
|
215
|
+
* @param content binary content to store
|
|
216
|
+
* @param contentType MIME type of the content (e.g. `"image/jpeg"`)
|
|
217
|
+
*/
|
|
218
|
+
async uploadEntryIfAbsent(name: string, content: Uint8Array, contentType: string): Promise<boolean> {
|
|
219
|
+
if (!this._containerClient) {
|
|
220
|
+
throw new Error("OINOBlobAzure: not connected")
|
|
221
|
+
}
|
|
222
|
+
const blockBlobClient = this._containerClient.getBlockBlobClient(name)
|
|
223
|
+
try {
|
|
224
|
+
await blockBlobClient.upload(content, content.length, {
|
|
225
|
+
blobHTTPHeaders: { blobContentType: contentType },
|
|
226
|
+
conditions: { ifNoneMatch: "*" } // succeed only if the blob does not exist
|
|
227
|
+
})
|
|
228
|
+
return true
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
// Blob already existed -> we lost the claim. Azure returns 409 (BlobAlreadyExists);
|
|
231
|
+
// 412 guards against SDK/version variance. Anything else is a real failure -> rethrow.
|
|
232
|
+
if (e && (e.statusCode === 409 || e.statusCode === 412)) {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
throw e
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Delete a named blob.
|
|
241
|
+
*
|
|
242
|
+
* @param name full blob name (path within the container)
|
|
243
|
+
*/
|
|
244
|
+
async deleteEntry(name: string): Promise<void> {
|
|
245
|
+
if (!this._containerClient) {
|
|
246
|
+
throw new Error("OINOBlobAzure: not connected")
|
|
247
|
+
}
|
|
248
|
+
const blobClient = this._containerClient.getBlobClient(name)
|
|
249
|
+
await blobClient.delete()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── OINODataSource datamodel initialisation ───────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Attach a static `OINOBlobDataModel` to the given API, adding all five
|
|
256
|
+
* standard fields that Azure Blob Storage returns in a listing.
|
|
257
|
+
*
|
|
258
|
+
* @param api the `OINOBlobApi` whose data model is to be initialised
|
|
259
|
+
*/
|
|
260
|
+
async initializeApiDatamodel(api: OINOApi): Promise<void> {
|
|
261
|
+
const blobApi = api as OINOBlobApi
|
|
262
|
+
const datamodel = new OINOBlobDataModel(blobApi)
|
|
263
|
+
const ds = this
|
|
264
|
+
const FIELD: OINODataFieldParams = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false }
|
|
265
|
+
const PK: OINODataFieldParams = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true }
|
|
266
|
+
datamodel.addField(new OINOStringDataField(ds, "name", "TEXT", PK, 1024))
|
|
267
|
+
datamodel.addField(new OINOStringDataField(ds, "etag", "TEXT", FIELD, 256))
|
|
268
|
+
datamodel.addField(new OINODatetimeDataField(ds, "lastModified", "DATETIME", FIELD))
|
|
269
|
+
datamodel.addField(new OINONumberDataField(ds, "contentLength", "INTEGER", FIELD))
|
|
270
|
+
datamodel.addField(new OINOStringDataField(ds, "contentType", "TEXT", FIELD, 256))
|
|
271
|
+
blobApi.initializeDatamodel(datamodel)
|
|
272
|
+
}
|
|
273
|
+
}
|