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/scripts.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileMaker script invocation.
|
|
3
|
+
*
|
|
4
|
+
* FMS exposes FileMaker scripts through the OData v4 Action mechanism at a
|
|
5
|
+
* `Script.<ScriptName>` path suffix. Scripts can be invoked at three scopes:
|
|
6
|
+
*
|
|
7
|
+
* POST /<db>/Script.<name> // database-level
|
|
8
|
+
* POST /<db>/<EntitySet>/Script.<name> // entity-set context
|
|
9
|
+
* POST /<db>/<EntitySet>(<key>)/Script.<name> // single-record context
|
|
10
|
+
*
|
|
11
|
+
* Starting with FileMaker Server 2026 (v26), scripts can also be invoked by
|
|
12
|
+
* their immutable FMSID instead of the name:
|
|
13
|
+
*
|
|
14
|
+
* POST /<db>/Script.FMSID:<id> // database-level
|
|
15
|
+
* POST /<db>/<EntitySet>/Script.FMSID:<id> // entity-set context
|
|
16
|
+
* POST /<db>/<EntitySet>(<key>)/Script.FMSID:<id> // single-record context
|
|
17
|
+
*
|
|
18
|
+
* The optional parameter is sent as `{ "scriptParameterValue": "<string>" }`. The
|
|
19
|
+
* response envelope is `{ "scriptResult": "...", "scriptError": "0" }`; a
|
|
20
|
+
* non-zero `scriptError` becomes an `FMScriptError`.
|
|
21
|
+
*
|
|
22
|
+
* @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { FMSOData } from './client.js'
|
|
26
|
+
import { FMScriptError } from './errors.js'
|
|
27
|
+
import { executeJson } from './http.js'
|
|
28
|
+
import type { RequestOptions } from './types.js'
|
|
29
|
+
import {
|
|
30
|
+
encodePathSegment,
|
|
31
|
+
escapeStringLiteral,
|
|
32
|
+
type ODataLiteral,
|
|
33
|
+
} from './url.js'
|
|
34
|
+
|
|
35
|
+
/** Script identifier: either by name or by FMSID (v26+). */
|
|
36
|
+
export type ScriptIdentifier =
|
|
37
|
+
| { type: 'name'; name: string }
|
|
38
|
+
| { type: 'fmsid'; id: number }
|
|
39
|
+
|
|
40
|
+
/** Options accepted by a script invocation. */
|
|
41
|
+
export interface ScriptOptions extends RequestOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Optional script parameter. Serialized to the FMS `scriptParameterValue`
|
|
44
|
+
* field in the request body. If omitted, the body is empty and the script
|
|
45
|
+
* runs with no parameter (FileMaker's `Get(ScriptParameter)` returns empty).
|
|
46
|
+
*/
|
|
47
|
+
parameter?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Result envelope returned by a successful script invocation. A non-zero
|
|
52
|
+
* `scriptError` is promoted to an `FMScriptError` before reaching the caller,
|
|
53
|
+
* so values you receive here always represent success (`scriptError === "0"`).
|
|
54
|
+
*/
|
|
55
|
+
export interface ScriptResult {
|
|
56
|
+
/** Raw value returned by `Exit Script [Text Result: ...]`. */
|
|
57
|
+
scriptResult?: string
|
|
58
|
+
/** Always `"0"` in a resolved result; non-zero raises `FMScriptError`. */
|
|
59
|
+
scriptError: string
|
|
60
|
+
/** Full parsed response body, for forward-compatible field access. */
|
|
61
|
+
raw: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Format an OData primary-key literal for embedding in a URL path. */
|
|
65
|
+
function formatKey(key: ODataLiteral): string {
|
|
66
|
+
if (typeof key === 'number') {
|
|
67
|
+
if (!Number.isFinite(key)) {
|
|
68
|
+
throw new TypeError('ScriptInvoker: key must be a finite number')
|
|
69
|
+
}
|
|
70
|
+
return String(key)
|
|
71
|
+
}
|
|
72
|
+
if (typeof key === 'string') return `'${escapeStringLiteral(key)}'`
|
|
73
|
+
if (typeof key === 'boolean') return key ? 'true' : 'false'
|
|
74
|
+
throw new TypeError('ScriptInvoker: unsupported key type')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Scope describing where a `ScriptInvoker` is rooted. */
|
|
78
|
+
export interface ScriptScope {
|
|
79
|
+
/** When omitted the script runs at database scope. */
|
|
80
|
+
entitySet?: string
|
|
81
|
+
/** When present alongside `entitySet`, the script runs at record scope. */
|
|
82
|
+
key?: ODataLiteral
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Low-level handle used internally by `FMSOData#script`, `Query#script`, and
|
|
87
|
+
* `EntityRef#script`. Exposed so advanced callers can build their own
|
|
88
|
+
* invocation paths if needed.
|
|
89
|
+
*/
|
|
90
|
+
export class ScriptInvoker {
|
|
91
|
+
/** @internal */ readonly _client: FMSOData
|
|
92
|
+
readonly entitySet: string | undefined
|
|
93
|
+
readonly key: ODataLiteral | undefined
|
|
94
|
+
|
|
95
|
+
constructor(client: FMSOData, scope: ScriptScope = {}) {
|
|
96
|
+
this._client = client
|
|
97
|
+
if (scope.entitySet !== undefined) this.entitySet = scope.entitySet
|
|
98
|
+
if (scope.key !== undefined) this.key = scope.key
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build the absolute URL for invoking `name` at this scope. */
|
|
102
|
+
url(name: string): string {
|
|
103
|
+
if (!name) throw new TypeError('ScriptInvoker: script name is required')
|
|
104
|
+
return this._urlForSegment(`Script.${encodePathSegment(name)}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Build the absolute URL for invoking by FMSID at this scope. */
|
|
108
|
+
urlById(fmsid: number): string {
|
|
109
|
+
if (!Number.isFinite(fmsid)) throw new TypeError('ScriptInvoker: fmsid must be a finite number')
|
|
110
|
+
return this._urlForSegment(`Script.FMSID:${fmsid}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @internal — build URL from a script path segment. */
|
|
114
|
+
private _urlForSegment(scriptSegment: string): string {
|
|
115
|
+
const base = this._client.baseUrl
|
|
116
|
+
if (this.entitySet === undefined) {
|
|
117
|
+
return `${base}/${scriptSegment}`
|
|
118
|
+
}
|
|
119
|
+
const setSegment = encodePathSegment(this.entitySet)
|
|
120
|
+
if (this.key === undefined) {
|
|
121
|
+
return `${base}/${setSegment}/${scriptSegment}`
|
|
122
|
+
}
|
|
123
|
+
return `${base}/${setSegment}(${formatKey(this.key)})/${scriptSegment}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Invoke the script by name. Resolves to a `ScriptResult` on success. */
|
|
127
|
+
async run(name: string, opts: ScriptOptions = {}): Promise<ScriptResult> {
|
|
128
|
+
return this._runAtUrl(this.url(name), opts)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Invoke the script by its immutable FMSID.
|
|
133
|
+
*
|
|
134
|
+
* Requires FileMaker Server 2026+ (v26). Use `db.hasFeature('scriptsByFMSID')`
|
|
135
|
+
* to check before calling.
|
|
136
|
+
*
|
|
137
|
+
* ```ts
|
|
138
|
+
* const result = await db.scriptById(42, { parameter: 'hello' })
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
async runById(fmsid: number, opts: ScriptOptions = {}): Promise<ScriptResult> {
|
|
142
|
+
return this._runAtUrl(this.urlById(fmsid), opts)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @internal — execute a script POST at the given URL. */
|
|
146
|
+
private async _runAtUrl(url: string, opts: ScriptOptions): Promise<ScriptResult> {
|
|
147
|
+
const headers: Record<string, string> = {}
|
|
148
|
+
let body: string | undefined
|
|
149
|
+
if (opts.parameter !== undefined) {
|
|
150
|
+
headers['Content-Type'] = 'application/json'
|
|
151
|
+
body = JSON.stringify({ scriptParameterValue: opts.parameter })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const method = 'POST'
|
|
155
|
+
const json = await executeJson<unknown>(this._client._ctx, url, {
|
|
156
|
+
method,
|
|
157
|
+
headers,
|
|
158
|
+
...(body !== undefined ? { body } : {}),
|
|
159
|
+
accept: 'json',
|
|
160
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
return parseScriptEnvelope(json, { url, method })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse the `{ scriptResult, scriptError }` envelope FMS returns from a
|
|
169
|
+
* script action, promoting a non-zero `scriptError` to `FMScriptError`.
|
|
170
|
+
*
|
|
171
|
+
* @internal
|
|
172
|
+
*/
|
|
173
|
+
export function parseScriptEnvelope(
|
|
174
|
+
raw: unknown,
|
|
175
|
+
request: { url: string; method: string },
|
|
176
|
+
): ScriptResult {
|
|
177
|
+
// FMS may wrap the envelope under a single top-level key in some versions;
|
|
178
|
+
// tolerate both shapes by looking for the fields at depth 0 or 1.
|
|
179
|
+
const envelope = extractEnvelope(raw)
|
|
180
|
+
|
|
181
|
+
// FMS v26+ wraps the result as: {"scriptResult": {"code": 0, "resultParameter": "..."}}
|
|
182
|
+
// Older FMS uses flat: {"scriptResult": "...", "scriptError": "0"}
|
|
183
|
+
const rawResult = envelope.scriptResult
|
|
184
|
+
let scriptResult: string | undefined
|
|
185
|
+
let scriptError: string
|
|
186
|
+
|
|
187
|
+
if (rawResult !== null && typeof rawResult === 'object' && 'resultParameter' in rawResult) {
|
|
188
|
+
// Nested envelope (FMS v26+): code + resultParameter inside scriptResult
|
|
189
|
+
const nested = rawResult as { code?: unknown; resultParameter?: unknown }
|
|
190
|
+
scriptError = nested.code !== undefined ? String(nested.code) : '0'
|
|
191
|
+
scriptResult = nested.resultParameter !== undefined ? String(nested.resultParameter) : undefined
|
|
192
|
+
} else {
|
|
193
|
+
// Flat envelope (older FMS): scriptResult is a string, scriptError is a sibling
|
|
194
|
+
scriptError = envelope.scriptError !== undefined ? String(envelope.scriptError) : '0'
|
|
195
|
+
scriptResult =
|
|
196
|
+
rawResult !== undefined
|
|
197
|
+
? typeof rawResult === 'string'
|
|
198
|
+
? rawResult
|
|
199
|
+
: JSON.stringify(rawResult)
|
|
200
|
+
: undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (scriptError !== '0') {
|
|
204
|
+
throw new FMScriptError(
|
|
205
|
+
`FileMaker script error ${scriptError}`,
|
|
206
|
+
{
|
|
207
|
+
status: 200,
|
|
208
|
+
scriptError,
|
|
209
|
+
...(scriptResult !== undefined ? { scriptResult } : {}),
|
|
210
|
+
odataError: raw,
|
|
211
|
+
request,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const out: ScriptResult = { scriptError, raw }
|
|
217
|
+
if (scriptResult !== undefined) out.scriptResult = scriptResult
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractEnvelope(raw: unknown): {
|
|
222
|
+
scriptResult?: unknown
|
|
223
|
+
scriptError?: unknown
|
|
224
|
+
} {
|
|
225
|
+
if (raw === null || typeof raw !== 'object') return {}
|
|
226
|
+
const obj = raw as Record<string, unknown>
|
|
227
|
+
if ('scriptError' in obj || 'scriptResult' in obj) {
|
|
228
|
+
return obj as { scriptResult?: unknown; scriptError?: unknown }
|
|
229
|
+
}
|
|
230
|
+
// Some callers may see the envelope nested under a single wrapping key.
|
|
231
|
+
for (const v of Object.values(obj)) {
|
|
232
|
+
if (v && typeof v === 'object') {
|
|
233
|
+
const inner = v as Record<string, unknown>
|
|
234
|
+
if ('scriptError' in inner || 'scriptResult' in inner) {
|
|
235
|
+
return inner as { scriptResult?: unknown; scriptError?: unknown }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @internal — convenience factory used by client/query/entity helpers. */
|
|
243
|
+
export function runScriptAtDatabase(
|
|
244
|
+
client: FMSOData,
|
|
245
|
+
name: string,
|
|
246
|
+
opts?: ScriptOptions,
|
|
247
|
+
): Promise<ScriptResult> {
|
|
248
|
+
return new ScriptInvoker(client).run(name, opts)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @internal — convenience factory for FMSID-based invocation. */
|
|
252
|
+
export function runScriptByIdAtDatabase(
|
|
253
|
+
client: FMSOData,
|
|
254
|
+
fmsid: number,
|
|
255
|
+
opts?: ScriptOptions,
|
|
256
|
+
): Promise<ScriptResult> {
|
|
257
|
+
return new ScriptInvoker(client).runById(fmsid, opts)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** @internal */
|
|
261
|
+
export function runScriptAtEntitySet(
|
|
262
|
+
client: FMSOData,
|
|
263
|
+
entitySet: string,
|
|
264
|
+
name: string,
|
|
265
|
+
opts?: ScriptOptions,
|
|
266
|
+
): Promise<ScriptResult> {
|
|
267
|
+
return new ScriptInvoker(client, { entitySet }).run(name, opts)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @internal */
|
|
271
|
+
export function runScriptAtEntity(
|
|
272
|
+
client: FMSOData,
|
|
273
|
+
entitySet: string,
|
|
274
|
+
key: ODataLiteral,
|
|
275
|
+
name: string,
|
|
276
|
+
opts?: ScriptOptions,
|
|
277
|
+
): Promise<ScriptResult> {
|
|
278
|
+
return new ScriptInvoker(client, { entitySet, key }).run(name, opts)
|
|
279
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public TypeScript types for fms-odata-js.
|
|
3
|
+
*
|
|
4
|
+
* These are intentionally minimal during M1 and will be expanded in later
|
|
5
|
+
* milestones (query, CRUD, containers, scripts, metadata, batch).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TokenProvider =
|
|
9
|
+
| string
|
|
10
|
+
| (() => string | Promise<string>)
|
|
11
|
+
|
|
12
|
+
export interface FMSODataOptions {
|
|
13
|
+
/** Base FMS host, e.g. `https://fms.example.com` (no trailing slash). */
|
|
14
|
+
host: string
|
|
15
|
+
/** FileMaker database (solution) name. */
|
|
16
|
+
database: string
|
|
17
|
+
/** Bearer token or resolver. */
|
|
18
|
+
token: TokenProvider
|
|
19
|
+
/** Invoked once on 401 to refresh credentials before a single retry. */
|
|
20
|
+
onUnauthorized?: () => void | Promise<void>
|
|
21
|
+
/** Injectable fetch implementation (defaults to `globalThis.fetch`). */
|
|
22
|
+
fetch?: typeof globalThis.fetch
|
|
23
|
+
/** Per-request timeout in milliseconds. */
|
|
24
|
+
timeoutMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Common request-level options accepted by every method. */
|
|
28
|
+
export interface RequestOptions {
|
|
29
|
+
signal?: AbortSignal
|
|
30
|
+
}
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OData URL encoding helpers.
|
|
3
|
+
*
|
|
4
|
+
* Aligned with `@fms-odata/spec-ts` for the shared helpers (`escapeStringLiteral`,
|
|
5
|
+
* `formatLiteral`). fms-odata-js keeps its richer implementations for
|
|
6
|
+
* `formatDateTime`, `parseDateTime`, `odataEncode`, `buildQueryString`, and
|
|
7
|
+
* `encodePathSegment` which go beyond the spec's minimal versions.
|
|
8
|
+
*
|
|
9
|
+
* - String literals are single-quoted with inner single-quotes doubled.
|
|
10
|
+
* - Dates serialize as UTC ISO-8601 without milliseconds (FileMaker quirk).
|
|
11
|
+
* - Query-string keys are emitted verbatim (always safe ASCII such as `$filter`)
|
|
12
|
+
* while values are percent-encoded via `odataEncode`, which uses `%20`
|
|
13
|
+
* for spaces rather than `+` (required by OData `$filter`) and preserves
|
|
14
|
+
* commas (required by `$select`, `$orderby`, `$expand`).
|
|
15
|
+
*
|
|
16
|
+
* @see https://github.com/fsans/fms-odata-spec/blob/main/docs/13-quirks.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Any value that can appear on the right-hand side of an OData `$filter` comparison. */
|
|
20
|
+
export type ODataLiteral = string | number | boolean | Date | null | undefined
|
|
21
|
+
|
|
22
|
+
/** Escape a string for inclusion inside a single-quoted OData literal. */
|
|
23
|
+
export function escapeStringLiteral(s: string): string {
|
|
24
|
+
return s.replace(/'/g, "''")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Format a `Date` as UTC ISO-8601 without milliseconds (e.g. `2026-04-17T14:45:00Z`). */
|
|
28
|
+
export function formatDateTime(d: Date): string {
|
|
29
|
+
if (!(d instanceof Date) || Number.isNaN(d.getTime())) {
|
|
30
|
+
throw new TypeError('formatDateTime: invalid Date')
|
|
31
|
+
}
|
|
32
|
+
return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse an OData DateTimeOffset string (with or without milliseconds). */
|
|
36
|
+
export function parseDateTime(s: string): Date {
|
|
37
|
+
const d = new Date(s)
|
|
38
|
+
if (Number.isNaN(d.getTime())) {
|
|
39
|
+
throw new TypeError(`parseDateTime: invalid date string: ${s}`)
|
|
40
|
+
}
|
|
41
|
+
return d
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Format a primitive as an OData literal suitable for `$filter`, `$search`, etc. */
|
|
45
|
+
export function formatLiteral(v: ODataLiteral): string {
|
|
46
|
+
if (v === null || v === undefined) return 'null'
|
|
47
|
+
if (typeof v === 'string') return `'${escapeStringLiteral(v)}'`
|
|
48
|
+
if (typeof v === 'number') {
|
|
49
|
+
if (!Number.isFinite(v)) {
|
|
50
|
+
throw new TypeError(`formatLiteral: cannot encode non-finite number: ${v}`)
|
|
51
|
+
}
|
|
52
|
+
return String(v)
|
|
53
|
+
}
|
|
54
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
|
55
|
+
if (v instanceof Date) return formatDateTime(v)
|
|
56
|
+
throw new TypeError(`formatLiteral: unsupported OData literal type: ${typeof v}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Encode a single URL path segment (spaces become `%20`). */
|
|
60
|
+
export function encodePathSegment(s: string): string {
|
|
61
|
+
return encodeURIComponent(s)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Encode an OData query-string value.
|
|
66
|
+
*
|
|
67
|
+
* - Spaces become `%20` (not `+`) — required by OData `$filter`.
|
|
68
|
+
* - Commas stay literal (not `%2C`) — required by `$select`, `$orderby`, `$expand`.
|
|
69
|
+
* - Parentheses stay literal (not `%28`/`%29`) — required by `$apply` expressions
|
|
70
|
+
* such as `aggregate(...)` and `groupby((...))`.
|
|
71
|
+
* - `$`, `=`, and `;` stay literal — required for nested `$expand` options.
|
|
72
|
+
* FileMaker Server rejects percent-encoded forms of these characters.
|
|
73
|
+
*/
|
|
74
|
+
export function odataEncode(v: string): string {
|
|
75
|
+
return encodeURIComponent(v)
|
|
76
|
+
.replace(/%2C/gi, ',')
|
|
77
|
+
.replace(/%24/g, '$')
|
|
78
|
+
.replace(/%3D/g, '=')
|
|
79
|
+
.replace(/%3B/g, ';')
|
|
80
|
+
.replace(/%28/g, '(')
|
|
81
|
+
.replace(/%29/g, ')')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a querystring from ordered `[key, value]` pairs.
|
|
86
|
+
*
|
|
87
|
+
* Keys are emitted verbatim (the caller controls them and they are always safe
|
|
88
|
+
* ASCII such as `$filter`). Values are percent-encoded via `odataEncode`.
|
|
89
|
+
* Empty / nullish values are skipped.
|
|
90
|
+
*/
|
|
91
|
+
export function buildQueryString(params: ReadonlyArray<readonly [string, string]>): string {
|
|
92
|
+
const parts: string[] = []
|
|
93
|
+
for (const [k, v] of params) {
|
|
94
|
+
if (v === '' || v === undefined || v === null) continue
|
|
95
|
+
parts.push(`${k}=${odataEncode(v)}`)
|
|
96
|
+
}
|
|
97
|
+
return parts.join('&')
|
|
98
|
+
}
|