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/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
+ }