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/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ export { FMSOData } from './client.js'
2
+ export { Batch, Changeset } from './batch.js'
3
+ export type {
4
+ BatchHandle,
5
+ BatchOpResult,
6
+ BatchReadOp,
7
+ BatchResult,
8
+ } from './batch.js'
9
+ export {
10
+ buildContainerJsonBody,
11
+ ContainerRef,
12
+ FM_CONTAINER_SUPPORTED_MIME_TYPES,
13
+ sniffContainerMime,
14
+ toBase64,
15
+ } from './containers.js'
16
+ export type {
17
+ ContainerDownload,
18
+ ContainerJsonValue,
19
+ ContainerUploadInput,
20
+ FMContainerMimeType,
21
+ } from './containers.js'
22
+ export { EntityRef } from './entity.js'
23
+ export type { EntityWriteOptions, EntityRefInfo } from './entity.js'
24
+ export { FMSODataError, FMScriptError, isFMSODataError, isFMScriptError } from './errors.js'
25
+ export type { ODataErrorBody } from './errors.js'
26
+ export { basicAuth, fmidAuth } from './http.js'
27
+ export type { FMAuthScheme, FMAuthToken, FMAuthTokenProvider } from './http.js'
28
+ export { MetadataFetcher } from './metadata.js'
29
+ export type {
30
+ EdmAction,
31
+ EdmEntitySet,
32
+ EdmEntityType,
33
+ EdmProperty,
34
+ MetadataOptions,
35
+ ODataMetadata,
36
+ } from './metadata.js'
37
+ export { Filter, Query, filterFactory } from './query.js'
38
+ export type { FilterFactory, FilterInput, OrderDir, QueryResult } from './query.js'
39
+ export type { AggregateFunction } from '@fms-odata/spec-ts'
40
+ export { ScriptInvoker } from './scripts.js'
41
+ export type { ScriptOptions, ScriptResult, ScriptScope, ScriptIdentifier } from './scripts.js'
42
+ export type { FMSODataOptions, TokenProvider, RequestOptions } from './types.js'
43
+ export type { ODataLiteral } from './url.js'
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Spec alignment: re-export version and feature-flag helpers from @fms-odata/spec-ts
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export {
50
+ FM_VERSION_NAMES,
51
+ FM_VERSION_MATRIX,
52
+ ODATA_PROTOCOL_VERSION,
53
+ hasFeature,
54
+ hasQueryOption,
55
+ minVersionForFeature,
56
+ parseServerVersion,
57
+ parseVersionString,
58
+ } from '@fms-odata/spec-ts'
59
+ export type {
60
+ FMVersionMajor,
61
+ FMVersionStatus,
62
+ FMFeatureFlags,
63
+ FMQueryOptionFlags,
64
+ FMVersionInfo,
65
+ FMServerVersion,
66
+ } from '@fms-odata/spec-ts'
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Deprecated aliases (kept for backward compatibility with fm-odata-js).
70
+ // These re-export the renamed symbols under their old names and will be
71
+ // removed in a future major version. New code should use the FMSOData* names.
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** @deprecated Use `FMSOData` instead. */
75
+ export { FMSOData as FMOData } from './client.js'
76
+ /** @deprecated Use `FMSODataError` instead. */
77
+ export { FMSODataError as FMODataError } from './errors.js'
78
+ /** @deprecated Use `isFMSODataError` instead. */
79
+ export { isFMSODataError as isFMODataError } from './errors.js'
80
+ /** @deprecated Use `FMSODataOptions` instead. */
81
+ export type { FMSODataOptions as FMODataOptions } from './types.js'
@@ -0,0 +1,354 @@
1
+ /**
2
+ * $metadata (EDMX/CSDL) fetch and lightweight parser.
3
+ *
4
+ * FMS returns a subset of the full OData CSDL spec. This module parses just
5
+ * what FMS emits: EntityTypes, EntitySets, and Actions (FileMaker scripts).
6
+ * No inheritance, complex types, or enums are expected.
7
+ *
8
+ * The parser is intentionally dependency-free and uses simple regex-based
9
+ * extraction to stay within the bundle budget (~1 KB gzipped).
10
+ */
11
+
12
+ import { FMSODataError } from './errors.js'
13
+ import { executeRequest, type HttpClientContext } from './http.js'
14
+ import type { RequestOptions } from './types.js'
15
+ import { parseServerVersion, type FMServerVersion } from '@fms-odata/spec-ts'
16
+
17
+ /** Property on an entity type (field in FileMaker). */
18
+ export interface EdmProperty {
19
+ name: string
20
+ type: string
21
+ nullable: boolean
22
+ maxLength?: number
23
+ }
24
+
25
+ /** Entity type (FileMaker table occurrence). */
26
+ export interface EdmEntityType {
27
+ name: string
28
+ keys: string[]
29
+ properties: EdmProperty[]
30
+ navigationProperties: { name: string; target: string; collection: boolean }[]
31
+ }
32
+
33
+ /** Entity set (exposed layout/table in OData). */
34
+ export interface EdmEntitySet {
35
+ name: string
36
+ entityType: string
37
+ }
38
+
39
+ /** Action (FileMaker script exposed as OData Action). */
40
+ export interface EdmAction {
41
+ name: string
42
+ boundTo?: string
43
+ parameters: { name: string; type: string }[]
44
+ }
45
+
46
+ /** Parsed OData metadata document. */
47
+ export interface ODataMetadata {
48
+ namespace: string
49
+ entityTypes: EdmEntityType[]
50
+ entitySets: EdmEntitySet[]
51
+ actions: EdmAction[]
52
+ /** Product version string extracted from Org.OData.Core.V1.ProductVersion annotation (if present). */
53
+ productVersion?: string
54
+ /** Full parsed server version (major.minor.patch + raw), if detected from $metadata. */
55
+ serverVersion?: FMServerVersion
56
+ /** Original XML, for debugging and forward compatibility. */
57
+ raw: string
58
+ }
59
+
60
+ /** Options for metadata fetch. */
61
+ export interface MetadataOptions extends RequestOptions {
62
+ /** Force refetch, bypassing the internal cache. */
63
+ refresh?: boolean
64
+ }
65
+
66
+ /** @internal — parse a boolean XML attribute value. */
67
+ function parseBoolAttr(value: string | undefined, defaultValue: boolean): boolean {
68
+ if (value === undefined) return defaultValue
69
+ return value === 'true'
70
+ }
71
+
72
+ /** @internal — extract the value of an XML attribute by name from a tag string. */
73
+ function getAttr(text: string, name: string): string | undefined {
74
+ const re = new RegExp(`${name}="([^"]*)"`, 'i')
75
+ const m = text.match(re)
76
+ return m?.[1]
77
+ }
78
+
79
+ /** @internal — extract all attributes as a record from a tag open. */
80
+ function getAttrs(text: string): Record<string, string> {
81
+ const out: Record<string, string> = {}
82
+ const re = /(\w+)="([^"]*)"/g
83
+ let m: RegExpExecArray | null
84
+ while ((m = re.exec(text)) !== null) {
85
+ // m[1] and m[2] are always defined when the regex matches
86
+ out[m[1]!] = m[2]!
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** @internal — find all top-level elements with the given tag name, handling namespace prefixes. */
92
+ function findElements(xml: string, tagName: string): string[] {
93
+ const results: string[] = []
94
+ // Match both <Tag ...> and <prefix:Tag ...> (self-closing or with content)
95
+ // [\\w]+ matches word characters (properly escaped for RegExp string)
96
+ const openRe = new RegExp(`<([\\w]+:)?${tagName}\\b[^>]*>`, 'gi')
97
+ let m: RegExpExecArray | null
98
+ while ((m = openRe.exec(xml)) !== null) {
99
+ const openTag = m[0]
100
+ // Self-closing tag: <... />
101
+ if (/\/>\s*$/.test(openTag)) {
102
+ results.push(openTag)
103
+ continue
104
+ }
105
+ // Find matching close tag; naive but adequate for FMS CSDL which isn't deeply nested.
106
+ const startIdx = m.index
107
+ const closeRe = new RegExp(`</([\\w]+:)?${tagName}>`, 'gi')
108
+ closeRe.lastIndex = openRe.lastIndex
109
+ const closeM = closeRe.exec(xml)
110
+ if (closeM) {
111
+ results.push(xml.slice(startIdx, closeM.index + closeM[0].length))
112
+ }
113
+ }
114
+ return results
115
+ }
116
+
117
+ /** @internal — extract inner XML between the outermost matching open/close tags (handles namespace prefixes). */
118
+ function elementContent(xml: string, tagName: string): string | undefined {
119
+ // Match tag with optional namespace prefix: <tag>, <prefix:tag>
120
+ const openRe = new RegExp(`<(?:[\\w]+:)?${tagName}\\b[^>]*>`, 'i')
121
+ const openM = openRe.exec(xml)
122
+ if (!openM) return undefined
123
+ const startIdx = openM.index + openM[0].length
124
+ // Match closing tag with optional namespace prefix
125
+ const closeRe = new RegExp(`</(?:[\\w]+:)?${tagName}>`, 'i')
126
+ closeRe.lastIndex = startIdx
127
+ const closeM = closeRe.exec(xml)
128
+ if (!closeM) return undefined
129
+ return xml.slice(startIdx, closeM.index)
130
+ }
131
+
132
+ /** @internal — parse <Property Name="..." Type="..." Nullable="..." [MaxLength="..."] /> */
133
+ function parseProperty(xml: string): EdmProperty {
134
+ const attrs = getAttrs(xml)
135
+ const nullable = parseBoolAttr(attrs.Nullable, true)
136
+ const out: EdmProperty = {
137
+ name: attrs.Name ?? '',
138
+ type: attrs.Type ?? '',
139
+ nullable,
140
+ }
141
+ if (attrs.MaxLength !== undefined) {
142
+ const n = parseInt(attrs.MaxLength, 10)
143
+ if (!Number.isNaN(n)) out.maxLength = n
144
+ }
145
+ return out
146
+ }
147
+
148
+ /** @internal — parse <NavigationProperty Name="..." Type="..." /> */
149
+ function parseNavigationProperty(xml: string): { name: string; target: string; collection: boolean } {
150
+ const attrs = getAttrs(xml)
151
+ const type = attrs.Type ?? ''
152
+ const collection = type.startsWith('Collection(')
153
+ const target = collection ? type.slice(11, -1) : type
154
+ return {
155
+ name: attrs.Name ?? '',
156
+ target,
157
+ collection,
158
+ }
159
+ }
160
+
161
+ /** @internal — parse <Key><PropertyRef Name="..." /></Key> */
162
+ function parseKey(xml: string): string[] {
163
+ const keys: string[] = []
164
+ const refs = findElements(xml, 'PropertyRef')
165
+ for (const ref of refs) {
166
+ const name = getAttr(ref, 'Name')
167
+ if (name) keys.push(name)
168
+ }
169
+ return keys
170
+ }
171
+
172
+ /** @internal — parse <EntityType Name="...">...</EntityType> */
173
+ function parseEntityType(xml: string): EdmEntityType {
174
+ const name = getAttr(xml, 'Name') ?? ''
175
+ const keys: string[] = []
176
+ const properties: EdmProperty[] = []
177
+ const navigationProperties: EdmEntityType['navigationProperties'] = []
178
+
179
+ const inner = elementContent(xml, 'EntityType') ?? xml
180
+
181
+ // Key
182
+ const keyEl = findElements(inner, 'Key')[0]
183
+ if (keyEl) {
184
+ keys.push(...parseKey(keyEl))
185
+ }
186
+
187
+ // Properties
188
+ for (const prop of findElements(inner, 'Property')) {
189
+ properties.push(parseProperty(prop))
190
+ }
191
+
192
+ // Navigation Properties
193
+ for (const nav of findElements(inner, 'NavigationProperty')) {
194
+ navigationProperties.push(parseNavigationProperty(nav))
195
+ }
196
+
197
+ return { name, keys, properties, navigationProperties }
198
+ }
199
+
200
+ /** @internal — parse <EntitySet Name="..." EntityType="..." /> */
201
+ function parseEntitySet(xml: string): EdmEntitySet {
202
+ const attrs = getAttrs(xml)
203
+ return {
204
+ name: attrs.Name ?? '',
205
+ entityType: attrs.EntityType ?? '',
206
+ }
207
+ }
208
+
209
+ /**
210
+ * @internal — extract the FileMaker Server version from the metadata XML using
211
+ * the multi-strategy parser from @fms-odata/spec-ts.
212
+ *
213
+ * Detection strategies (in priority order):
214
+ * 1a. Org.OData.Core.V1.ProductVersion annotation with String attribute
215
+ * 1b. Same as 1a but with reversed attribute order
216
+ * 1c. ServerVersion annotation (FM 26+, e.g. "OData Engine 26.0.1")
217
+ * 1d. Same as 1c but with reversed attribute order
218
+ * 2. Generic fallback: any annotation term containing "Version" with major >= 17
219
+ *
220
+ * Returns the full FMServerVersion object, or undefined if not found.
221
+ */
222
+ function extractServerVersion(xml: string): FMServerVersion | undefined {
223
+ return parseServerVersion(xml) ?? undefined
224
+ }
225
+
226
+ /** @internal — parse <Action Name="...">[...parameters...]</Action> */
227
+ function parseAction(xml: string): EdmAction {
228
+ const name = getAttr(xml, 'Name') ?? ''
229
+ const attrs = getAttrs(xml)
230
+ const boundTo = attrs['IsBound'] === 'true' ? attrs['EntityType'] : undefined
231
+
232
+ const parameters: EdmAction['parameters'] = []
233
+ const inner = elementContent(xml, 'Action') ?? xml
234
+ for (const p of findElements(inner, 'Parameter')) {
235
+ const pAttrs = getAttrs(p)
236
+ parameters.push({
237
+ name: pAttrs.Name ?? '',
238
+ type: pAttrs.Type ?? '',
239
+ })
240
+ }
241
+
242
+ return boundTo !== undefined ? { name, boundTo, parameters } : { name, parameters }
243
+ }
244
+
245
+ /**
246
+ * @internal — parse raw CSDL XML into ODataMetadata.
247
+ * Throws FMSODataError for malformed XML.
248
+ */
249
+ export function parseMetadata(xml: string): ODataMetadata {
250
+ try {
251
+ // Extract Edmx/DataServices/Schema
252
+ const dataServices = elementContent(xml, 'DataServices')
253
+ if (!dataServices) {
254
+ throw new Error('Missing <edmx:DataServices> in metadata')
255
+ }
256
+
257
+ const schema = findElements(dataServices, 'Schema')[0]
258
+ if (!schema) {
259
+ throw new Error('Missing <Schema> in metadata')
260
+ }
261
+
262
+ const namespace = getAttr(schema, 'Namespace') ?? ''
263
+ const schemaInner = elementContent(schema, 'Schema') ?? schema
264
+
265
+ // Parse EntityTypes
266
+ const entityTypes: EdmEntityType[] = []
267
+ for (const et of findElements(schemaInner, 'EntityType')) {
268
+ entityTypes.push(parseEntityType(et))
269
+ }
270
+
271
+ // Parse EntitySets (inside EntityContainer)
272
+ const entitySets: EdmEntitySet[] = []
273
+ const container = findElements(schemaInner, 'EntityContainer')[0]
274
+ if (container) {
275
+ const containerInner = elementContent(container, 'EntityContainer') ?? container
276
+ for (const es of findElements(containerInner, 'EntitySet')) {
277
+ entitySets.push(parseEntitySet(es))
278
+ }
279
+ }
280
+
281
+ // Parse Actions (FileMaker scripts surface here)
282
+ const actions: EdmAction[] = []
283
+ for (const a of findElements(schemaInner, 'Action')) {
284
+ actions.push(parseAction(a))
285
+ }
286
+
287
+ // Extract server version using the multi-strategy parser from @fms-odata/spec-ts.
288
+ // Handles ProductVersion, ServerVersion (FM 26+), reversed attribute order,
289
+ // and a generic fallback for non-standard annotation terms.
290
+ const serverVersion = extractServerVersion(xml)
291
+
292
+ const result: ODataMetadata = {
293
+ namespace,
294
+ entityTypes,
295
+ entitySets,
296
+ actions,
297
+ raw: xml,
298
+ }
299
+ if (serverVersion) {
300
+ result.serverVersion = serverVersion
301
+ // Keep productVersion for backward compatibility (raw string only)
302
+ result.productVersion = serverVersion.raw
303
+ }
304
+ return result
305
+ } catch (err) {
306
+ const message = err instanceof Error ? err.message : String(err)
307
+ throw new FMSODataError(`Failed to parse $metadata: ${message}`, {
308
+ status: 0,
309
+ odataError: xml.slice(0, 500),
310
+ })
311
+ }
312
+ }
313
+
314
+ /**
315
+ * @internal — fetch raw $metadata XML.
316
+ */
317
+ export async function fetchMetadataXml(
318
+ ctx: HttpClientContext,
319
+ baseUrl: string,
320
+ opts: RequestOptions = {},
321
+ ): Promise<string> {
322
+ const res = await executeRequest(ctx, `${baseUrl}/$metadata`, {
323
+ method: 'GET',
324
+ accept: 'xml',
325
+ ...(opts.signal ? { signal: opts.signal } : {}),
326
+ })
327
+ return res.text()
328
+ }
329
+
330
+ /**
331
+ * Metadata fetcher bound to a client. Caches by default; pass `refresh: true` to bypass.
332
+ * @internal — exported for testing; use via `FMSOData#metadata()`.
333
+ */
334
+ export class MetadataFetcher {
335
+ private _cache: Promise<ODataMetadata> | undefined
336
+
337
+ constructor(
338
+ private _ctx: HttpClientContext,
339
+ private _baseUrl: string,
340
+ ) {}
341
+
342
+ async fetchXml(opts: RequestOptions = {}): Promise<string> {
343
+ return fetchMetadataXml(this._ctx, this._baseUrl, opts)
344
+ }
345
+
346
+ async fetch(opts: MetadataOptions = {}): Promise<ODataMetadata> {
347
+ if (!opts.refresh && this._cache) {
348
+ return this._cache
349
+ }
350
+ const promise = this.fetchXml(opts).then(parseMetadata)
351
+ this._cache = promise
352
+ return promise
353
+ }
354
+ }