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/entity.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-entity handle returned by `Query#byKey(key)`. Supports `.get()`,
|
|
3
|
+
* `.patch()`, `.delete()`, `.script()` (record-scope FileMaker script
|
|
4
|
+
* invocation, v0.1.4+), and `.container(field)` (container-field binary
|
|
5
|
+
* I/O, v0.1.5+).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FMSOData } from './client.js'
|
|
9
|
+
import {
|
|
10
|
+
buildContainerJsonBody,
|
|
11
|
+
ContainerRef,
|
|
12
|
+
type ContainerJsonValue,
|
|
13
|
+
} from './containers.js'
|
|
14
|
+
import { executeJson, executeRequest } from './http.js'
|
|
15
|
+
import { runScriptAtEntity, type ScriptOptions, type ScriptResult } from './scripts.js'
|
|
16
|
+
import type { RequestOptions } from './types.js'
|
|
17
|
+
import {
|
|
18
|
+
encodePathSegment,
|
|
19
|
+
escapeStringLiteral,
|
|
20
|
+
type ODataLiteral,
|
|
21
|
+
} from './url.js'
|
|
22
|
+
|
|
23
|
+
/** Options accepted by mutating entity operations. */
|
|
24
|
+
export interface EntityWriteOptions extends RequestOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Optional `If-Match` precondition. Pass an ETag returned from a prior read
|
|
27
|
+
* to enable optimistic concurrency.
|
|
28
|
+
*/
|
|
29
|
+
ifMatch?: string
|
|
30
|
+
/**
|
|
31
|
+
* Whether the server should return the updated representation. Defaults to
|
|
32
|
+
* `false` (`Prefer: return=minimal`) to match FMS behaviour; set to `true`
|
|
33
|
+
* to request `Prefer: return=representation`.
|
|
34
|
+
*/
|
|
35
|
+
returnRepresentation?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format an OData primary-key literal for embedding in a URL path
|
|
40
|
+
* (`EntitySet(<key>)`).
|
|
41
|
+
*/
|
|
42
|
+
function formatKey(key: ODataLiteral): string {
|
|
43
|
+
if (typeof key === 'number') {
|
|
44
|
+
if (!Number.isFinite(key)) {
|
|
45
|
+
throw new TypeError('EntityRef: key must be a finite number')
|
|
46
|
+
}
|
|
47
|
+
return String(key)
|
|
48
|
+
}
|
|
49
|
+
if (typeof key === 'string') return `'${escapeStringLiteral(key)}'`
|
|
50
|
+
if (typeof key === 'boolean') return key ? 'true' : 'false'
|
|
51
|
+
throw new TypeError('EntityRef: unsupported key type')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Handle to a single OData entity. Holds the client, entity-set name, and the
|
|
56
|
+
* primary key; every method builds a URL of the form
|
|
57
|
+
* `<baseUrl>/<EntitySet>(<key>)` and delegates to the shared HTTP executor.
|
|
58
|
+
*/
|
|
59
|
+
export class EntityRef<T = Record<string, unknown>> {
|
|
60
|
+
readonly entitySet: string
|
|
61
|
+
readonly key: ODataLiteral
|
|
62
|
+
|
|
63
|
+
/** @internal */ readonly _client: FMSOData
|
|
64
|
+
|
|
65
|
+
constructor(client: FMSOData, entitySet: string, key: ODataLiteral) {
|
|
66
|
+
this._client = client
|
|
67
|
+
this.entitySet = entitySet
|
|
68
|
+
this.key = key
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Absolute URL for this entity. */
|
|
72
|
+
toURL(): string {
|
|
73
|
+
return `${this._client.baseUrl}/${encodePathSegment(this.entitySet)}(${formatKey(this.key)})`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `GET` the entity. Returns the parsed JSON row. */
|
|
77
|
+
async get(opts: RequestOptions = {}): Promise<T> {
|
|
78
|
+
const json = await executeJson<T>(this._client._ctx, this.toURL(), {
|
|
79
|
+
method: 'GET',
|
|
80
|
+
accept: 'json',
|
|
81
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
82
|
+
})
|
|
83
|
+
return json
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `GET` a single field's scalar value via the OData property URL
|
|
88
|
+
* (`…/<EntitySet>(<key>)/<fieldName>`). FMS responds with the JSON envelope
|
|
89
|
+
* `{ value: … }`; this method unwraps it and returns just the value.
|
|
90
|
+
*
|
|
91
|
+
* Useful when you only need one column without composing a `$select` query.
|
|
92
|
+
* For container fields use `container(name).get()` instead.
|
|
93
|
+
*/
|
|
94
|
+
async fieldValue<V = unknown>(fieldName: string, opts: RequestOptions = {}): Promise<V> {
|
|
95
|
+
const url = `${this.toURL()}/${encodePathSegment(fieldName)}`
|
|
96
|
+
const json = await executeJson<{ value: V }>(this._client._ctx, url, {
|
|
97
|
+
method: 'GET',
|
|
98
|
+
accept: 'json',
|
|
99
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
100
|
+
})
|
|
101
|
+
return json.value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `PATCH` the entity with partial values. Returns the updated row when the
|
|
106
|
+
* server echoes one (OData `Prefer: return=representation`), otherwise
|
|
107
|
+
* `undefined` on `204 No Content`.
|
|
108
|
+
*/
|
|
109
|
+
async patch(
|
|
110
|
+
body: Partial<T> | Record<string, unknown>,
|
|
111
|
+
opts: EntityWriteOptions = {},
|
|
112
|
+
): Promise<T | undefined> {
|
|
113
|
+
const headers: Record<string, string> = {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
Prefer: opts.returnRepresentation ? 'return=representation' : 'return=minimal',
|
|
116
|
+
}
|
|
117
|
+
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
118
|
+
|
|
119
|
+
const json = await executeJson<T | undefined>(this._client._ctx, this.toURL(), {
|
|
120
|
+
method: 'PATCH',
|
|
121
|
+
headers,
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
accept: 'json',
|
|
124
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
125
|
+
})
|
|
126
|
+
return json
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `DELETE` the entity. Resolves on success; throws `FMSODataError` otherwise. */
|
|
130
|
+
async delete(opts: EntityWriteOptions = {}): Promise<void> {
|
|
131
|
+
const headers: Record<string, string> = {}
|
|
132
|
+
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
133
|
+
|
|
134
|
+
await executeRequest(this._client._ctx, this.toURL(), {
|
|
135
|
+
method: 'DELETE',
|
|
136
|
+
headers,
|
|
137
|
+
accept: 'none',
|
|
138
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Invoke a FileMaker script in the context of this single record. FMS sets
|
|
144
|
+
* the script's current record to this entity before running it.
|
|
145
|
+
*/
|
|
146
|
+
async script(name: string, opts: ScriptOptions = {}): Promise<ScriptResult> {
|
|
147
|
+
return runScriptAtEntity(this._client, this.entitySet, this.key, name, opts)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get a typed handle to one of this record's container fields, exposing
|
|
152
|
+
* `.get()`, `.getStream()`, `.upload(...)`, and `.delete()`.
|
|
153
|
+
*/
|
|
154
|
+
container(fieldName: string): ContainerRef {
|
|
155
|
+
return new ContainerRef(this as EntityRef<unknown>, fieldName)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Update one or more container fields (and optionally regular fields) on
|
|
160
|
+
* this record in a single base64 PATCH request. This maps to the Claris
|
|
161
|
+
* "Operation 3" (`PATCH /<EntitySet>(<key>)` with JSON body containing
|
|
162
|
+
* `<field>`, `<field>@com.filemaker.odata.ContentType`, and
|
|
163
|
+
* `<field>@com.filemaker.odata.Filename`).
|
|
164
|
+
*
|
|
165
|
+
* Each container value's `data` must already be base64-encoded (use the
|
|
166
|
+
* library's exported `toBase64()` helper or `Buffer.from(bytes).toString('base64')`).
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* await db.from('contact').byKey(7).patchContainers(
|
|
170
|
+
* {
|
|
171
|
+
* photo: { data: photoB64, contentType: 'image/png', filename: 'p.png' },
|
|
172
|
+
* contract: { data: contractB64, contentType: 'application/pdf', filename: 'c.pdf' },
|
|
173
|
+
* },
|
|
174
|
+
* { website: 'https://example.com' },
|
|
175
|
+
* )
|
|
176
|
+
*/
|
|
177
|
+
async patchContainers(
|
|
178
|
+
containers: Record<string, ContainerJsonValue>,
|
|
179
|
+
regularFields: Record<string, unknown> = {},
|
|
180
|
+
opts: EntityWriteOptions = {},
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const headers: Record<string, string> = {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
Prefer: opts.returnRepresentation ? 'return=representation' : 'return=minimal',
|
|
185
|
+
}
|
|
186
|
+
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
187
|
+
|
|
188
|
+
const body = buildContainerJsonBody(containers, regularFields)
|
|
189
|
+
await executeRequest(this._client._ctx, this.toURL(), {
|
|
190
|
+
method: 'PATCH',
|
|
191
|
+
headers,
|
|
192
|
+
body: JSON.stringify(body),
|
|
193
|
+
accept: 'none',
|
|
194
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
// Record references ($ref) — OData standard, supported since FMS 19
|
|
200
|
+
// -------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get the references for a navigation property on this record.
|
|
204
|
+
*
|
|
205
|
+
* `GET /<EntitySet>(<key>)/<navProperty>/$ref`
|
|
206
|
+
*
|
|
207
|
+
* Returns an array of entity references. For a single-valued navigation
|
|
208
|
+
* property, the array has at most one element.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* const refs = await db.from('contact').byKey(7).getRefs('addresses')
|
|
213
|
+
* // [{ '@odata.id': 'https://fms.example.com/fmi/odata/v4/DB/address(1)' }, ...]
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
async getRefs(navProperty: string, opts: RequestOptions = {}): Promise<EntityRefInfo[]> {
|
|
217
|
+
const url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`
|
|
218
|
+
const json = await executeJson<{ value?: EntityRefInfo[]; '@odata.id'?: string }>(
|
|
219
|
+
this._client._ctx,
|
|
220
|
+
url,
|
|
221
|
+
{
|
|
222
|
+
method: 'GET',
|
|
223
|
+
accept: 'json',
|
|
224
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
// Single-valued nav properties return { "@odata.id": "..." } directly;
|
|
228
|
+
// collection nav properties return { value: [{ "@odata.id": "..." }, ...] }
|
|
229
|
+
if (json?.value) return json.value
|
|
230
|
+
if (json?.['@odata.id']) return [json as EntityRefInfo]
|
|
231
|
+
return []
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Add a reference to a related record via a navigation property.
|
|
236
|
+
*
|
|
237
|
+
* `POST /<EntitySet>(<key>)/<navProperty>/$ref`
|
|
238
|
+
*
|
|
239
|
+
* For single-valued navigation properties, use `setRef()` instead (PATCH).
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* await db.from('contact').byKey(7).addRef('addresses', 42)
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
async addRef(navProperty: string, relatedKey: string | number, opts: EntityWriteOptions = {}): Promise<void> {
|
|
247
|
+
const url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`
|
|
248
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
249
|
+
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
250
|
+
|
|
251
|
+
const body = JSON.stringify({ '@odata.id': this._refId(navProperty, relatedKey) })
|
|
252
|
+
await executeRequest(this._client._ctx, url, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers,
|
|
255
|
+
body,
|
|
256
|
+
accept: 'none',
|
|
257
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Set (replace) the reference for a single-valued navigation property.
|
|
263
|
+
*
|
|
264
|
+
* `PATCH /<EntitySet>(<key>)/<navProperty>/$ref`
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* await db.from('order').byKey(100).setRef('customer', 7)
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
async setRef(navProperty: string, relatedKey: string | number, opts: EntityWriteOptions = {}): Promise<void> {
|
|
272
|
+
const url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`
|
|
273
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
274
|
+
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
275
|
+
|
|
276
|
+
const body = JSON.stringify({ '@odata.id': this._refId(navProperty, relatedKey) })
|
|
277
|
+
await executeRequest(this._client._ctx, url, {
|
|
278
|
+
method: 'PATCH',
|
|
279
|
+
headers,
|
|
280
|
+
body,
|
|
281
|
+
accept: 'none',
|
|
282
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Remove a reference from a navigation property.
|
|
288
|
+
*
|
|
289
|
+
* `DELETE /<EntitySet>(<key>)/<navProperty>/$ref`
|
|
290
|
+
*
|
|
291
|
+
* For collection-valued navigation properties, pass the `relatedKey` to
|
|
292
|
+
* remove a specific reference. For single-valued, omit `relatedKey` to
|
|
293
|
+
* clear the reference.
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```ts
|
|
297
|
+
* await db.from('contact').byKey(7).removeRef('addresses', 42)
|
|
298
|
+
* await db.from('order').byKey(100).removeRef('customer')
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
async removeRef(navProperty: string, relatedKey?: string | number, opts: EntityWriteOptions = {}): Promise<void> {
|
|
302
|
+
let url = `${this.toURL()}/${encodePathSegment(navProperty)}/$ref`
|
|
303
|
+
const headers: Record<string, string> = {}
|
|
304
|
+
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
|
305
|
+
|
|
306
|
+
if (relatedKey !== undefined) {
|
|
307
|
+
// For collection nav properties, target the specific reference by ID
|
|
308
|
+
url += `('${escapeStringLiteral(this._refId(navProperty, relatedKey))}')`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await executeRequest(this._client._ctx, url, {
|
|
312
|
+
method: 'DELETE',
|
|
313
|
+
headers,
|
|
314
|
+
accept: 'none',
|
|
315
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** @internal — build a relative @odata.id for a related entity. */
|
|
320
|
+
private _refId(navProperty: string, relatedKey: string | number): string {
|
|
321
|
+
// The @odata.id can be a relative URL or a full URL. We use a relative
|
|
322
|
+
// form that's portable across deployments: just the entity set name and key.
|
|
323
|
+
// FMS resolves this relative to the service root.
|
|
324
|
+
const navSet = navProperty // The navigation property name typically matches the target entity set
|
|
325
|
+
if (typeof relatedKey === 'number') return `${navSet}(${relatedKey})`
|
|
326
|
+
return `${navSet}('${escapeStringLiteral(relatedKey)}')`
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Entity reference info returned by `getRefs()`. */
|
|
331
|
+
export interface EntityRefInfo {
|
|
332
|
+
'@odata.id': string
|
|
333
|
+
}
|
|
334
|
+
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized error thrown by all fms-odata-js operations.
|
|
3
|
+
*
|
|
4
|
+
* The error class shape is aligned with `@fms-odata/spec-ts` (the canonical
|
|
5
|
+
* spec package). fms-odata-js keeps its own class identity for backward
|
|
6
|
+
* compatibility with existing `instanceof` checks in user code.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/fsans/fms-odata-spec/blob/main/docs/13-quirks.md
|
|
9
|
+
*/
|
|
10
|
+
export class FMSODataError extends Error {
|
|
11
|
+
readonly status: number
|
|
12
|
+
readonly code: string | undefined
|
|
13
|
+
readonly odataError: unknown
|
|
14
|
+
readonly request: { url: string; method: string } | undefined
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
init: {
|
|
19
|
+
status: number
|
|
20
|
+
code?: string
|
|
21
|
+
odataError?: unknown
|
|
22
|
+
request?: { url: string; method: string }
|
|
23
|
+
},
|
|
24
|
+
) {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = 'FMSODataError'
|
|
27
|
+
this.status = init.status
|
|
28
|
+
if (init.code !== undefined) this.code = init.code
|
|
29
|
+
if (init.odataError !== undefined) this.odataError = init.odataError
|
|
30
|
+
if (init.request !== undefined) this.request = init.request
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse an error Response body into a `FMSODataError`. Handles both stock OData
|
|
36
|
+
* JSON envelopes (`{ error: { code, message } }`) and FileMaker's XML envelope
|
|
37
|
+
* (`<m:error><m:code>212</m:code><m:message>...</m:message></m:error>`).
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export async function parseErrorResponse(
|
|
42
|
+
res: Response,
|
|
43
|
+
request: { url: string; method: string },
|
|
44
|
+
): Promise<FMSODataError> {
|
|
45
|
+
const status = res.status
|
|
46
|
+
let body = ''
|
|
47
|
+
try {
|
|
48
|
+
body = await res.text()
|
|
49
|
+
} catch {
|
|
50
|
+
// body unreadable; fall through with empty string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let code: string | undefined
|
|
54
|
+
let message = res.statusText || `HTTP ${status}`
|
|
55
|
+
let odataError: unknown = body
|
|
56
|
+
|
|
57
|
+
const ctype = (res.headers.get('content-type') ?? '').toLowerCase()
|
|
58
|
+
const looksJson = ctype.includes('json') || (body.startsWith('{') && body.endsWith('}'))
|
|
59
|
+
const looksXml = ctype.includes('xml') || body.trimStart().startsWith('<?xml') || body.includes('<m:error')
|
|
60
|
+
|
|
61
|
+
if (looksJson) {
|
|
62
|
+
try {
|
|
63
|
+
const json = JSON.parse(body) as { error?: { code?: string; message?: string | { value?: string } } }
|
|
64
|
+
odataError = json
|
|
65
|
+
const errCode = json?.error?.code
|
|
66
|
+
const rawMsg = json?.error?.message
|
|
67
|
+
const msg = typeof rawMsg === 'string' ? rawMsg : rawMsg?.value
|
|
68
|
+
if (errCode) code = String(errCode)
|
|
69
|
+
if (msg) message = msg
|
|
70
|
+
} catch {
|
|
71
|
+
// leave defaults
|
|
72
|
+
}
|
|
73
|
+
} else if (looksXml) {
|
|
74
|
+
const codeMatch = body.match(/<m:code>([^<]+)<\/m:code>/)
|
|
75
|
+
const msgMatch = body.match(/<m:message(?:\s[^>]*)?>([^<]+)<\/m:message>/)
|
|
76
|
+
if (codeMatch?.[1]) code = codeMatch[1]
|
|
77
|
+
if (msgMatch?.[1]) message = msgMatch[1]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return new FMSODataError(message, { status, ...(code !== undefined ? { code } : {}), odataError, request })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Thrown when a FileMaker script invocation completes with a non-zero
|
|
85
|
+
* `scriptError`. The HTTP request itself succeeded (the server returned 2xx),
|
|
86
|
+
* but the script reported an error via the FMS result envelope.
|
|
87
|
+
*
|
|
88
|
+
* Subclass of `FMSODataError` so existing `instanceof FMSODataError` checks and
|
|
89
|
+
* error-handling code keep working.
|
|
90
|
+
*/
|
|
91
|
+
export class FMScriptError extends FMSODataError {
|
|
92
|
+
/** FileMaker script error code as a string (e.g. `"101"`). */
|
|
93
|
+
readonly scriptError: string
|
|
94
|
+
/** Raw `scriptResult` value returned by the script, if any. */
|
|
95
|
+
readonly scriptResult: string | undefined
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
message: string,
|
|
99
|
+
init: {
|
|
100
|
+
status: number
|
|
101
|
+
scriptError: string
|
|
102
|
+
scriptResult?: string
|
|
103
|
+
odataError?: unknown
|
|
104
|
+
request?: { url: string; method: string }
|
|
105
|
+
},
|
|
106
|
+
) {
|
|
107
|
+
super(message, {
|
|
108
|
+
status: init.status,
|
|
109
|
+
code: init.scriptError,
|
|
110
|
+
...(init.odataError !== undefined ? { odataError: init.odataError } : {}),
|
|
111
|
+
...(init.request !== undefined ? { request: init.request } : {}),
|
|
112
|
+
})
|
|
113
|
+
this.name = 'FMScriptError'
|
|
114
|
+
this.scriptError = init.scriptError
|
|
115
|
+
if (init.scriptResult !== undefined) this.scriptResult = init.scriptResult
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Spec alignment: re-export type-level helpers from @fms-odata/spec-ts
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/** OData standard error response body shape (from spec). */
|
|
124
|
+
export type { ODataErrorBody } from '@fms-odata/spec-ts'
|
|
125
|
+
|
|
126
|
+
/** Check if an error is a FileMaker OData error. */
|
|
127
|
+
export function isFMSODataError(err: unknown): err is FMSODataError {
|
|
128
|
+
return err instanceof FMSODataError
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Check if an error is a FileMaker script error. */
|
|
132
|
+
export function isFMScriptError(err: unknown): err is FMScriptError {
|
|
133
|
+
return err instanceof FMScriptError
|
|
134
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP plumbing shared by the query builder, entity handles, and (later)
|
|
3
|
+
* containers, scripts, metadata, and batch.
|
|
4
|
+
*
|
|
5
|
+
* Aligned with `@fms-odata/spec-ts` for auth type definitions. fms-odata-js
|
|
6
|
+
* keeps its own `resolveAuthHeader`, `basicAuth`, `combineSignals`, and
|
|
7
|
+
* `executeRequest` implementations which are more feature-rich (401 retry,
|
|
8
|
+
* timeout composition, browser/Web Viewer compatibility).
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Authorization header resolution (Basic, Bearer, or FMID, auto-detected).
|
|
12
|
+
* - Timeout + AbortSignal composition.
|
|
13
|
+
* - 401 retry via `onUnauthorized` (once).
|
|
14
|
+
* - Error envelope normalization into `FMSODataError`.
|
|
15
|
+
*
|
|
16
|
+
* @see https://github.com/fsans/fms-odata-spec/blob/main/docs/04-authentication.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parseErrorResponse, FMSODataError } from './errors.js'
|
|
20
|
+
import type { TokenProvider, RequestOptions } from './types.js'
|
|
21
|
+
|
|
22
|
+
/** A scheme-prefixed Authorization value (e.g. `Basic …`, `Bearer …`, or `FMID …`). */
|
|
23
|
+
const AUTH_SCHEME_RE = /^(basic|bearer|fmid|negotiate|digest)\s+\S/i
|
|
24
|
+
|
|
25
|
+
/** Resolve a `TokenProvider` to a complete Authorization header value. */
|
|
26
|
+
export async function resolveAuthHeader(provider: TokenProvider): Promise<string> {
|
|
27
|
+
const raw =
|
|
28
|
+
typeof provider === 'function'
|
|
29
|
+
? await provider()
|
|
30
|
+
: provider
|
|
31
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
32
|
+
throw new TypeError('fms-odata-js: token resolver produced an empty value')
|
|
33
|
+
}
|
|
34
|
+
return AUTH_SCHEME_RE.test(raw) ? raw : `Bearer ${raw}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Encode an FMS account username + password into a `Basic …` header value. */
|
|
38
|
+
export function basicAuth(user: string, password: string): string {
|
|
39
|
+
const raw = `${user}:${password}`
|
|
40
|
+
// Prefer Buffer (Node); fall back to btoa (browser / Web Viewer).
|
|
41
|
+
const b64 =
|
|
42
|
+
typeof Buffer !== 'undefined'
|
|
43
|
+
? Buffer.from(raw, 'utf8').toString('base64')
|
|
44
|
+
: btoa(unescape(encodeURIComponent(raw)))
|
|
45
|
+
return `Basic ${b64}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build an FMID auth header value for FileMaker Cloud (Claris ID token). */
|
|
49
|
+
export function fmidAuth(token: string): string {
|
|
50
|
+
return `FMID ${token}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Auth scheme type (aligned with spec). */
|
|
54
|
+
export type { FMAuthScheme, FMAuthToken, FMAuthTokenProvider } from '@fms-odata/spec-ts'
|
|
55
|
+
|
|
56
|
+
/** Combine multiple `AbortSignal`s into one that aborts when any input aborts. */
|
|
57
|
+
export function combineSignals(
|
|
58
|
+
signals: ReadonlyArray<AbortSignal | undefined>,
|
|
59
|
+
): AbortSignal | undefined {
|
|
60
|
+
const filtered = signals.filter((s): s is AbortSignal => s !== undefined)
|
|
61
|
+
if (filtered.length === 0) return undefined
|
|
62
|
+
if (filtered.length === 1) return filtered[0]
|
|
63
|
+
const ctrl = new AbortController()
|
|
64
|
+
for (const s of filtered) {
|
|
65
|
+
if (s.aborted) {
|
|
66
|
+
ctrl.abort((s as AbortSignal & { reason?: unknown }).reason)
|
|
67
|
+
return ctrl.signal
|
|
68
|
+
}
|
|
69
|
+
s.addEventListener(
|
|
70
|
+
'abort',
|
|
71
|
+
() => ctrl.abort((s as AbortSignal & { reason?: unknown }).reason),
|
|
72
|
+
{ once: true },
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return ctrl.signal
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options accepted by the shared request executor. */
|
|
79
|
+
export interface HttpRequestOptions extends RequestOptions {
|
|
80
|
+
method?: string
|
|
81
|
+
headers?: HeadersInit
|
|
82
|
+
body?: BodyInit | null
|
|
83
|
+
/** Expected response shape; controls default Accept header. */
|
|
84
|
+
accept?: 'json' | 'xml' | 'text' | 'binary' | 'none'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Context supplied by `FMSOData` to execute a request. */
|
|
88
|
+
export interface HttpClientContext {
|
|
89
|
+
token: TokenProvider
|
|
90
|
+
onUnauthorized?: () => void | Promise<void>
|
|
91
|
+
fetch: typeof globalThis.fetch
|
|
92
|
+
timeoutMs: number | undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ACCEPT_DEFAULTS: Record<NonNullable<HttpRequestOptions['accept']>, string> = {
|
|
96
|
+
json: 'application/json',
|
|
97
|
+
xml: 'application/xml',
|
|
98
|
+
text: 'text/plain',
|
|
99
|
+
binary: 'application/octet-stream',
|
|
100
|
+
none: '*/*',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Execute an HTTP request against the FMS OData endpoint. Centralizes auth,
|
|
105
|
+
* timeout, retry, and error handling. Returns the raw `Response` on success.
|
|
106
|
+
*/
|
|
107
|
+
export async function executeRequest(
|
|
108
|
+
ctx: HttpClientContext,
|
|
109
|
+
url: string,
|
|
110
|
+
opts: HttpRequestOptions = {},
|
|
111
|
+
): Promise<Response> {
|
|
112
|
+
return executeRequestImpl(ctx, url, opts, /* retried */ false)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function executeRequestImpl(
|
|
116
|
+
ctx: HttpClientContext,
|
|
117
|
+
url: string,
|
|
118
|
+
opts: HttpRequestOptions,
|
|
119
|
+
retried: boolean,
|
|
120
|
+
): Promise<Response> {
|
|
121
|
+
const method = opts.method ?? 'GET'
|
|
122
|
+
const headers = new Headers(opts.headers)
|
|
123
|
+
headers.set('Authorization', await resolveAuthHeader(ctx.token))
|
|
124
|
+
// Required by the Claris FMS OData API for all requests.
|
|
125
|
+
if (!headers.has('OData-Version')) headers.set('OData-Version', '4.0')
|
|
126
|
+
if (!headers.has('OData-MaxVersion')) headers.set('OData-MaxVersion', '4.0')
|
|
127
|
+
if (!headers.has('Accept')) {
|
|
128
|
+
headers.set('Accept', ACCEPT_DEFAULTS[opts.accept ?? 'json'])
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timeoutCtrl = new AbortController()
|
|
132
|
+
const timeoutId =
|
|
133
|
+
ctx.timeoutMs && ctx.timeoutMs > 0
|
|
134
|
+
? setTimeout(() => timeoutCtrl.abort(new Error(`Timeout after ${ctx.timeoutMs}ms`)), ctx.timeoutMs)
|
|
135
|
+
: undefined
|
|
136
|
+
|
|
137
|
+
const signal = combineSignals([opts.signal, ctx.timeoutMs ? timeoutCtrl.signal : undefined])
|
|
138
|
+
|
|
139
|
+
let res: Response
|
|
140
|
+
try {
|
|
141
|
+
res = await ctx.fetch(url, {
|
|
142
|
+
method,
|
|
143
|
+
headers,
|
|
144
|
+
keepalive: true,
|
|
145
|
+
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
146
|
+
...(signal ? { signal } : {}),
|
|
147
|
+
})
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
150
|
+
// AbortError (timeout or caller cancellation) propagates as-is.
|
|
151
|
+
throw err
|
|
152
|
+
}
|
|
153
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
154
|
+
|
|
155
|
+
if (res.status === 401 && ctx.onUnauthorized && !retried) {
|
|
156
|
+
await ctx.onUnauthorized()
|
|
157
|
+
return executeRequestImpl(ctx, url, opts, true)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
throw await parseErrorResponse(res, { url, method })
|
|
162
|
+
}
|
|
163
|
+
return res
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Convenience: execute and parse the response as JSON. */
|
|
167
|
+
export async function executeJson<T = unknown>(
|
|
168
|
+
ctx: HttpClientContext,
|
|
169
|
+
url: string,
|
|
170
|
+
opts: HttpRequestOptions = {},
|
|
171
|
+
): Promise<T> {
|
|
172
|
+
const res = await executeRequest(ctx, url, opts)
|
|
173
|
+
if (res.status === 204) return undefined as T
|
|
174
|
+
const ctype = (res.headers.get('content-type') ?? '').toLowerCase()
|
|
175
|
+
if (!ctype.includes('json')) {
|
|
176
|
+
// Some FMS responses omit the header on success; try to parse anyway.
|
|
177
|
+
const text = await res.text()
|
|
178
|
+
if (!text) return undefined as T
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(text) as T
|
|
181
|
+
} catch {
|
|
182
|
+
throw new FMSODataError(`Expected JSON response, got "${ctype || 'no content-type'}"`, {
|
|
183
|
+
status: res.status,
|
|
184
|
+
odataError: text,
|
|
185
|
+
request: { url, method: opts.method ?? 'GET' },
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return (await res.json()) as T
|
|
190
|
+
}
|