fms-odata-js 0.3.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/CHANGELOG.md +160 -0
- package/LICENSE +22 -0
- package/README.md +381 -0
- package/dist/batch.d.ts +154 -0
- package/dist/batch.d.ts.map +1 -0
- package/dist/client.d.ts +160 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/containers.d.ts +190 -0
- package/dist/containers.d.ts.map +1 -0
- package/dist/entity.d.ts +149 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/errors.d.ts +69 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fms-odata.esm.js +2201 -0
- package/dist/fms-odata.esm.min.js +6 -0
- package/dist/fms-odata.iife.min.js +6 -0
- package/dist/http.d.ts +51 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/metadata.d.ts +85 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/query.d.ts +198 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/scripts.d.ts +114 -0
- package/dist/scripts.d.ts.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/url.d.ts +49 -0
- package/dist/url.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/batch.ts +477 -0
- package/src/client.ts +268 -0
- package/src/containers.ts +457 -0
- package/src/entity.ts +334 -0
- package/src/errors.ts +134 -0
- package/src/http.ts +190 -0
- package/src/index.ts +81 -0
- package/src/metadata.ts +354 -0
- package/src/query.ts +442 -0
- package/src/scripts.ts +279 -0
- package/src/types.ts +30 -0
- package/src/url.ts +98 -0
package/src/batch.ts
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* $batch multipart composer and response parser (M6).
|
|
3
|
+
*
|
|
4
|
+
* OData $batch allows multiple operations in a single HTTP round-trip.
|
|
5
|
+
* A batch consists of:
|
|
6
|
+
* - Read operations (GET) at the top level
|
|
7
|
+
* - Changesets (atomic groups of writes: POST, PATCH, DELETE)
|
|
8
|
+
*
|
|
9
|
+
* FMS endpoint: POST /fmi/odata/v4/{db}/$batch
|
|
10
|
+
* Content-Type: multipart/mixed; boundary=batch_<uuid>
|
|
11
|
+
*
|
|
12
|
+
* The implementation generates unique boundaries using crypto.randomUUID()
|
|
13
|
+
* (available in Node 18+ and modern browsers), composes the multipart
|
|
14
|
+
* body, and parses the multipart response back into per-operation results.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ KNOWN FMS LIMITATIONS (pending investigation — tested against FMS 21.x):
|
|
17
|
+
*
|
|
18
|
+
* 1. GET operations must NOT appear before a changeset.
|
|
19
|
+
* Placing a read op before a changeset causes FMS to return HTTP 400:
|
|
20
|
+
* "Expected batch boundary at 'Content-Type: applic...'".
|
|
21
|
+
* FMS's parser appears to misread the changeset's nested boundary as a
|
|
22
|
+
* batch-level boundary when GETs precede it.
|
|
23
|
+
* → Workaround: always add changesets before reads in the batch.
|
|
24
|
+
*
|
|
25
|
+
* 2. Multiple consecutive GET operations return only one fewer response than
|
|
26
|
+
* expected — the last GET in a sequence is silently dropped by FMS.
|
|
27
|
+
* E.g. three GETs → only two responses.
|
|
28
|
+
* → Workaround: limit batches to one read op at the end, or accept the loss.
|
|
29
|
+
*
|
|
30
|
+
* 3. POST/PATCH within a changeset requires an explicit Content-Length header
|
|
31
|
+
* on the inner HTTP request (FMS cannot determine body length from multipart
|
|
32
|
+
* structure alone). This is handled by utf8ByteLength() during serialisation.
|
|
33
|
+
*
|
|
34
|
+
* These constraints differ from the OData 4.01 spec and the official Claris
|
|
35
|
+
* documentation examples. They were discovered empirically. Batch support in
|
|
36
|
+
* this library is functional but limited to: changeset (optional) followed by
|
|
37
|
+
* at most one read operation per batch call.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { FMSOData } from './client.js'
|
|
41
|
+
import { executeRequest, type HttpRequestOptions } from './http.js'
|
|
42
|
+
import type { ODataLiteral } from './url.js'
|
|
43
|
+
import { encodePathSegment, odataEncode } from './url.js'
|
|
44
|
+
import type { RequestOptions } from './types.js'
|
|
45
|
+
|
|
46
|
+
/** Handle returned when adding an operation to a batch. Resolves to the operation result. */
|
|
47
|
+
export interface BatchHandle<T> {
|
|
48
|
+
readonly __brand: 'BatchHandle'
|
|
49
|
+
/** @internal */ readonly _promise: Promise<T>
|
|
50
|
+
/** @internal */ readonly _index: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Input for a read operation (GET) in a batch. */
|
|
54
|
+
export interface BatchReadOp {
|
|
55
|
+
/** Entity set name (layout/table). */
|
|
56
|
+
entitySet: string
|
|
57
|
+
/** Operation type. */
|
|
58
|
+
op: 'list'
|
|
59
|
+
/** Query options: $top, $skip, $filter, etc. */
|
|
60
|
+
query?: { $top?: number; $skip?: number; $filter?: string; $select?: string }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Result of a batch operation. */
|
|
64
|
+
export interface BatchOpResult<T = unknown> {
|
|
65
|
+
status: number
|
|
66
|
+
body?: T
|
|
67
|
+
headers: Headers
|
|
68
|
+
ok: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Result of executing a batch. */
|
|
72
|
+
export interface BatchResult {
|
|
73
|
+
/** All responses in request order. */
|
|
74
|
+
responses: BatchOpResult[]
|
|
75
|
+
/** True if all responses have status < 400. */
|
|
76
|
+
ok: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @internal — byte length of a string in UTF-8. */
|
|
80
|
+
function utf8ByteLength(str: string): number {
|
|
81
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
82
|
+
return new TextEncoder().encode(str).byteLength
|
|
83
|
+
}
|
|
84
|
+
// Node.js fallback
|
|
85
|
+
return Buffer.byteLength(str, 'utf8')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @internal — generate a unique boundary string. */
|
|
89
|
+
function generateBoundary(prefix: string): string {
|
|
90
|
+
// crypto.randomUUID() is available in Node 18+ and modern browsers
|
|
91
|
+
const uuid =
|
|
92
|
+
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
93
|
+
? crypto.randomUUID()
|
|
94
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
95
|
+
return `${prefix}_${uuid}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @internal — build the URL path for an entity set query. */
|
|
99
|
+
function buildEntitySetPath(baseUrl: string, entitySet: string, query?: BatchReadOp['query']): string {
|
|
100
|
+
// Build OData query string manually to avoid URLSearchParams percent-encoding
|
|
101
|
+
// the leading '$' in OData system query options ($top, $filter, etc.).
|
|
102
|
+
// Use odataEncode which preserves commas (required by $select, $orderby, $expand).
|
|
103
|
+
const parts: string[] = []
|
|
104
|
+
if (query?.$top !== undefined) parts.push(`$top=${odataEncode(String(query.$top))}`)
|
|
105
|
+
if (query?.$skip !== undefined) parts.push(`$skip=${odataEncode(String(query.$skip))}`)
|
|
106
|
+
if (query?.$filter) parts.push(`$filter=${odataEncode(query.$filter)}`)
|
|
107
|
+
if (query?.$select) parts.push(`$select=${odataEncode(query.$select)}`)
|
|
108
|
+
const qs = parts.join('&')
|
|
109
|
+
const encodedSet = encodePathSegment(entitySet)
|
|
110
|
+
return qs ? `${baseUrl}/${encodedSet}?${qs}` : `${baseUrl}/${encodedSet}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @internal — build the URL path for a single entity. */
|
|
114
|
+
function buildEntityPath(baseUrl: string, entitySet: string, key: ODataLiteral): string {
|
|
115
|
+
const encodedSet = encodePathSegment(entitySet)
|
|
116
|
+
let keySegment: string
|
|
117
|
+
if (typeof key === 'number') {
|
|
118
|
+
keySegment = String(key)
|
|
119
|
+
} else if (typeof key === 'string') {
|
|
120
|
+
keySegment = `'${key.replace(/'/g, "''")}'`
|
|
121
|
+
} else if (typeof key === 'boolean') {
|
|
122
|
+
keySegment = key ? 'true' : 'false'
|
|
123
|
+
} else {
|
|
124
|
+
throw new TypeError('Batch: unsupported key type')
|
|
125
|
+
}
|
|
126
|
+
return `${baseUrl}/${encodedSet}(${keySegment})`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Represents an operation within a changeset. */
|
|
130
|
+
interface ChangesetOp {
|
|
131
|
+
method: string
|
|
132
|
+
path: string
|
|
133
|
+
headers?: Record<string, string>
|
|
134
|
+
body?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Changeset builder for atomic write operations. */
|
|
138
|
+
export class Changeset {
|
|
139
|
+
private _ops: ChangesetOp[] = []
|
|
140
|
+
private _handles: Array<{ resolve: (v: unknown) => void; reject: (e: unknown) => void }> = []
|
|
141
|
+
private _baseUrl: string
|
|
142
|
+
|
|
143
|
+
constructor(baseUrl: string) {
|
|
144
|
+
this._baseUrl = baseUrl
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @internal */
|
|
148
|
+
get _operations(): ChangesetOp[] {
|
|
149
|
+
return this._ops
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @internal */
|
|
153
|
+
get _handleSlots(): Array<{ resolve: (v: unknown) => void; reject: (e: unknown) => void }> {
|
|
154
|
+
return this._handles
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create a new entity within this changeset.
|
|
159
|
+
*/
|
|
160
|
+
create<T = unknown>(entitySet: string, body: Record<string, unknown>): BatchHandle<T> {
|
|
161
|
+
const path = buildEntitySetPath(this._baseUrl, entitySet)
|
|
162
|
+
let resolve: (v: unknown) => void
|
|
163
|
+
let reject: (e: unknown) => void
|
|
164
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
165
|
+
resolve = res
|
|
166
|
+
reject = rej
|
|
167
|
+
})
|
|
168
|
+
const index = this._handles.length
|
|
169
|
+
this._handles.push({ resolve: resolve!, reject: reject! })
|
|
170
|
+
this._ops.push({
|
|
171
|
+
method: 'POST',
|
|
172
|
+
path,
|
|
173
|
+
headers: { 'Content-Type': 'application/json' },
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
})
|
|
176
|
+
return { __brand: 'BatchHandle', _promise: promise, _index: index } as BatchHandle<T>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Patch an existing entity within this changeset.
|
|
181
|
+
*/
|
|
182
|
+
patch<T = unknown>(
|
|
183
|
+
entitySet: string,
|
|
184
|
+
key: ODataLiteral,
|
|
185
|
+
body: Record<string, unknown>,
|
|
186
|
+
opts?: { ifMatch?: string },
|
|
187
|
+
): BatchHandle<T> {
|
|
188
|
+
const path = buildEntityPath(this._baseUrl, entitySet, key)
|
|
189
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
190
|
+
if (opts?.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
191
|
+
let resolve: (v: unknown) => void
|
|
192
|
+
let reject: (e: unknown) => void
|
|
193
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
194
|
+
resolve = res
|
|
195
|
+
reject = rej
|
|
196
|
+
})
|
|
197
|
+
const index = this._handles.length
|
|
198
|
+
this._handles.push({ resolve: resolve!, reject: reject! })
|
|
199
|
+
this._ops.push({
|
|
200
|
+
method: 'PATCH',
|
|
201
|
+
path,
|
|
202
|
+
headers,
|
|
203
|
+
body: JSON.stringify(body),
|
|
204
|
+
})
|
|
205
|
+
return { __brand: 'BatchHandle', _promise: promise, _index: index } as BatchHandle<T>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Delete an entity within this changeset.
|
|
210
|
+
*/
|
|
211
|
+
delete(entitySet: string, key: ODataLiteral, opts?: { ifMatch?: string }): BatchHandle<void> {
|
|
212
|
+
const path = buildEntityPath(this._baseUrl, entitySet, key)
|
|
213
|
+
const headers: Record<string, string> = {}
|
|
214
|
+
if (opts?.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
215
|
+
let resolve: (v: unknown) => void
|
|
216
|
+
let reject: (e: unknown) => void
|
|
217
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
218
|
+
resolve = res
|
|
219
|
+
reject = rej
|
|
220
|
+
})
|
|
221
|
+
const index = this._handles.length
|
|
222
|
+
this._handles.push({ resolve: resolve!, reject: reject! })
|
|
223
|
+
this._ops.push({ method: 'DELETE', path, headers })
|
|
224
|
+
return { __brand: 'BatchHandle', _promise: promise, _index: index } as BatchHandle<void>
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Internal representation of a batch part. */
|
|
229
|
+
interface BatchPart {
|
|
230
|
+
type: 'read' | 'changeset'
|
|
231
|
+
content: ChangesetOp | Changeset
|
|
232
|
+
handle?: BatchHandle<unknown>
|
|
233
|
+
changesetIndex?: number // for changeset parts, index into changeset handles
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Batch builder for composing multipart/mixed requests. */
|
|
237
|
+
export class Batch {
|
|
238
|
+
private _client: FMSOData
|
|
239
|
+
private _parts: BatchPart[] = []
|
|
240
|
+
private _changesets: Changeset[] = []
|
|
241
|
+
|
|
242
|
+
constructor(client: FMSOData) {
|
|
243
|
+
this._client = client
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Add a read operation (GET) to the batch.
|
|
248
|
+
* Read operations are not part of a changeset and execute independently.
|
|
249
|
+
*/
|
|
250
|
+
add<T = unknown>(op: BatchReadOp): BatchHandle<T> {
|
|
251
|
+
const path = buildEntitySetPath(this._client.baseUrl, op.entitySet, op.query)
|
|
252
|
+
let resolve: (v: unknown) => void
|
|
253
|
+
let reject: (e: unknown) => void
|
|
254
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
255
|
+
resolve = res
|
|
256
|
+
reject = rej
|
|
257
|
+
})
|
|
258
|
+
const handle = { __brand: 'BatchHandle', _promise: promise, _index: this._parts.length } as BatchHandle<T>
|
|
259
|
+
this._parts.push({
|
|
260
|
+
type: 'read',
|
|
261
|
+
content: { method: 'GET', path },
|
|
262
|
+
handle,
|
|
263
|
+
})
|
|
264
|
+
return handle
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Create an atomic changeset (group of write operations).
|
|
269
|
+
* All operations in a changeset succeed or fail together.
|
|
270
|
+
*/
|
|
271
|
+
changeset(build: (cs: Changeset) => void): void {
|
|
272
|
+
const cs = new Changeset(this._client.baseUrl)
|
|
273
|
+
build(cs)
|
|
274
|
+
this._changesets.push(cs)
|
|
275
|
+
// Add a placeholder part for the changeset (actual ops are inside cs)
|
|
276
|
+
this._parts.push({ type: 'changeset', content: cs })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Serialize the batch into a multipart/mixed body.
|
|
281
|
+
* @internal
|
|
282
|
+
*/
|
|
283
|
+
_serialize(): { boundary: string; body: string } {
|
|
284
|
+
const batchBoundary = generateBoundary('batch')
|
|
285
|
+
const lines: string[] = []
|
|
286
|
+
|
|
287
|
+
for (const part of this._parts) {
|
|
288
|
+
lines.push(`--${batchBoundary}`)
|
|
289
|
+
|
|
290
|
+
if (part.type === 'read') {
|
|
291
|
+
const op = part.content as ChangesetOp
|
|
292
|
+
lines.push('Content-Type: application/http')
|
|
293
|
+
lines.push('Content-Transfer-Encoding: binary')
|
|
294
|
+
lines.push('')
|
|
295
|
+
lines.push(`${op.method} ${op.path} HTTP/1.1`)
|
|
296
|
+
} else if (part.type === 'changeset') {
|
|
297
|
+
const cs = part.content as Changeset
|
|
298
|
+
const csBoundary = generateBoundary('changeset')
|
|
299
|
+
lines.push('Content-Type: multipart/mixed; boundary=' + csBoundary)
|
|
300
|
+
lines.push('')
|
|
301
|
+
|
|
302
|
+
for (const op of cs._operations) {
|
|
303
|
+
lines.push(`--${csBoundary}`)
|
|
304
|
+
lines.push('Content-Type: application/http')
|
|
305
|
+
lines.push('Content-Transfer-Encoding: binary')
|
|
306
|
+
lines.push('')
|
|
307
|
+
lines.push(`${op.method} ${op.path} HTTP/1.1`)
|
|
308
|
+
if (op.headers) {
|
|
309
|
+
for (const [k, v] of Object.entries(op.headers)) {
|
|
310
|
+
lines.push(`${k}: ${v}`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (op.body) {
|
|
314
|
+
lines.push(`Content-Length: ${utf8ByteLength(op.body)}`)
|
|
315
|
+
lines.push('')
|
|
316
|
+
lines.push(op.body)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
lines.push(`--${csBoundary}--`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
lines.push(`--${batchBoundary}--`)
|
|
324
|
+
return { boundary: batchBoundary, body: lines.join('\r\n') }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Send the batch request and parse the multipart response.
|
|
329
|
+
*/
|
|
330
|
+
async send(opts: RequestOptions = {}): Promise<BatchResult> {
|
|
331
|
+
const { boundary, body } = this._serialize()
|
|
332
|
+
|
|
333
|
+
const res = await executeRequest(
|
|
334
|
+
this._client._ctx,
|
|
335
|
+
`${this._client.baseUrl}/$batch`,
|
|
336
|
+
{
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: {
|
|
339
|
+
'Content-Type': `multipart/mixed; boundary=${boundary}`,
|
|
340
|
+
},
|
|
341
|
+
body,
|
|
342
|
+
accept: 'none',
|
|
343
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
const responseText = await res.text()
|
|
348
|
+
return this._parseResponse(responseText, res.headers.get('content-type') ?? '')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parse a multipart/mixed batch response.
|
|
353
|
+
* @internal
|
|
354
|
+
*/
|
|
355
|
+
_parseResponse(responseText: string, contentType: string): BatchResult {
|
|
356
|
+
// Extract boundary from content-type header
|
|
357
|
+
const boundaryMatch = contentType.match(/boundary=([^;\s]+)/)
|
|
358
|
+
const boundary = boundaryMatch?.[1]
|
|
359
|
+
if (!boundary) {
|
|
360
|
+
throw new Error('Batch response missing boundary in Content-Type')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Split by batch boundary
|
|
364
|
+
const parts = responseText.split(`--${boundary}`)
|
|
365
|
+
const results: BatchOpResult[] = []
|
|
366
|
+
let partIndex = 0
|
|
367
|
+
|
|
368
|
+
// First part is preamble (usually empty), last is epilogue
|
|
369
|
+
for (let i = 1; i < parts.length - 1; i++) {
|
|
370
|
+
const part = parts[i]!.trim()
|
|
371
|
+
if (!part || part === '--') continue
|
|
372
|
+
|
|
373
|
+
const batchPart = this._parts[partIndex]
|
|
374
|
+
if (!batchPart) continue
|
|
375
|
+
|
|
376
|
+
if (batchPart.type === 'read') {
|
|
377
|
+
const result = this._parseHttpPart(part)
|
|
378
|
+
results.push(result)
|
|
379
|
+
if (batchPart.handle) {
|
|
380
|
+
this._resolveHandle(batchPart.handle, result)
|
|
381
|
+
}
|
|
382
|
+
partIndex++
|
|
383
|
+
} else if (batchPart.type === 'changeset') {
|
|
384
|
+
const cs = batchPart.content as Changeset
|
|
385
|
+
const csResults = this._parseChangesetResponse(part, cs)
|
|
386
|
+
results.push(...csResults)
|
|
387
|
+
// Resolve or reject all changeset handles based on atomicity
|
|
388
|
+
const failed = csResults.find(r => !r.ok)
|
|
389
|
+
for (let j = 0; j < cs._handleSlots.length; j++) {
|
|
390
|
+
const slot = cs._handleSlots[j]!
|
|
391
|
+
const csResult = csResults[j]
|
|
392
|
+
if (failed) {
|
|
393
|
+
slot.reject(new Error(`Changeset failed: ${failed.status}`))
|
|
394
|
+
} else if (csResult) {
|
|
395
|
+
slot.resolve(csResult.body)
|
|
396
|
+
} else {
|
|
397
|
+
slot.resolve(undefined)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
partIndex++
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
responses: results,
|
|
406
|
+
ok: results.every(r => r.ok),
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** @internal — parse a single HTTP response part. */
|
|
411
|
+
private _parseHttpPart(part: string): BatchOpResult {
|
|
412
|
+
// Each MIME part has outer headers (e.g. Content-Type: application/http),
|
|
413
|
+
// then a blank line (\r\n\r\n), then the inner HTTP response.
|
|
414
|
+
// Inner HTTP response: status line + inner headers + blank line + body.
|
|
415
|
+
|
|
416
|
+
// Step 1: skip outer MIME headers to reach the inner HTTP response
|
|
417
|
+
const outerEnd = part.indexOf('\r\n\r\n')
|
|
418
|
+
const innerHttpText = outerEnd >= 0 ? part.slice(outerEnd + 4) : part
|
|
419
|
+
|
|
420
|
+
// Step 2: split inner HTTP response into head (status + headers) and body
|
|
421
|
+
const innerHeadEnd = innerHttpText.indexOf('\r\n\r\n')
|
|
422
|
+
const innerHead = innerHeadEnd >= 0 ? innerHttpText.slice(0, innerHeadEnd) : innerHttpText
|
|
423
|
+
const innerBody = innerHeadEnd >= 0 ? innerHttpText.slice(innerHeadEnd + 4).trim() : ''
|
|
424
|
+
|
|
425
|
+
// Step 3: extract status code from the HTTP status line (e.g. "HTTP/1.1 200 OK")
|
|
426
|
+
const statusMatch = innerHead.match(/^HTTP\/1\.\d (\d+)/)
|
|
427
|
+
const status = statusMatch ? parseInt(statusMatch[1]!, 10) : 0
|
|
428
|
+
|
|
429
|
+
// Step 4: parse body as JSON if the inner headers indicate application/json
|
|
430
|
+
let parsedBody: unknown = undefined
|
|
431
|
+
if (innerBody && innerHead.toLowerCase().includes('application/json')) {
|
|
432
|
+
try {
|
|
433
|
+
parsedBody = JSON.parse(innerBody)
|
|
434
|
+
} catch {
|
|
435
|
+
parsedBody = innerBody
|
|
436
|
+
}
|
|
437
|
+
} else if (innerBody) {
|
|
438
|
+
parsedBody = innerBody
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
status,
|
|
443
|
+
body: parsedBody,
|
|
444
|
+
headers: new Headers(),
|
|
445
|
+
ok: status >= 200 && status < 300,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** @internal — parse a changeset multipart response. */
|
|
450
|
+
private _parseChangesetResponse(part: string, cs: Changeset): BatchOpResult[] {
|
|
451
|
+
// Extract changeset boundary
|
|
452
|
+
const boundaryMatch = part.match(/boundary=([^\s]+)/)
|
|
453
|
+
const csBoundary = boundaryMatch?.[1]
|
|
454
|
+
if (!csBoundary) {
|
|
455
|
+
// Single response (error case)
|
|
456
|
+
return [this._parseHttpPart(part)]
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const csParts = part.split(`--${csBoundary}`)
|
|
460
|
+
const results: BatchOpResult[] = []
|
|
461
|
+
|
|
462
|
+
// Parts 1 to length-2 are actual responses
|
|
463
|
+
for (let i = 1; i < csParts.length - 1; i++) {
|
|
464
|
+
const csPart = csParts[i]!.trim()
|
|
465
|
+
if (!csPart || csPart === '--') continue
|
|
466
|
+
results.push(this._parseHttpPart(csPart))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return results
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** @internal — resolve a batch handle with the result. */
|
|
473
|
+
private _resolveHandle(handle: BatchHandle<unknown>, result: BatchOpResult): void {
|
|
474
|
+
// The handle's promise is resolved via the Changeset or direct assignment
|
|
475
|
+
// This is handled in the caller (_parseResponse)
|
|
476
|
+
}
|
|
477
|
+
}
|