@jd-data-limited/easy-fm 1.0.0-alpha1 → 1.0.2-alpha1

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/index.ts CHANGED
@@ -1,708 +1,819 @@
1
- /*
2
- * Copyright (c) 2022. See LICENSE file for more information
3
- */
4
-
5
- // const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
6
- import fetch, {
7
- Blob,
8
- blobFrom,
9
- blobFromSync,
10
- File,
11
- fileFrom,
12
- fileFromSync,
13
- FormData,
14
- Headers,
15
- Request,
16
- Response
17
- } from 'node-fetch';
18
- import { EventEmitter } from "events";
19
- // @ts-ignore
20
- import fs from "fs"
21
- // @ts-ignore
22
- import path from "path"
23
- // @ts-ignore
24
- import { fileURLToPath } from "url"
25
- import * as https from "https";
26
- // import * as btoa from "btoa";
27
-
28
- interface connectionOptions {
29
- hostname: string | undefined,
30
- database: databaseOptions
31
- }
32
-
33
- interface hostConnectionOptions {
34
- hostname: string
35
- database: loginOptionsOAuth | loginOptionsFileMaker | loginOptionsClaris | loginOptionsToken
36
- }
37
-
38
- interface databaseOptions {
39
- method: any
40
- database: string
41
- }
42
-
43
- interface loginOptionsToken extends databaseOptions {
44
- method: "token"
45
- token: string
46
- }
47
-
48
- interface loginOptionsOAuth extends databaseOptions {
49
- method: "oauth"
50
- oauth: {
51
- requestId: string,
52
- requestIdentifier: string
53
- }
54
- }
55
-
56
- interface loginOptionsFileMaker extends databaseOptions {
57
- method: "filemaker"
58
- username: string,
59
- password: string,
60
- }
61
-
62
- interface loginOptionsClaris extends databaseOptions {
63
- method: "claris"
64
- claris: {
65
- fmid: string
66
- }
67
- }
68
-
69
- // @ts-ignore
70
- const errs = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'fmErrors.json')).toString())
71
-
72
- interface Script {
73
- name: string
74
- parameter: string
75
- }
76
-
77
- interface recordObject {
78
- recordId: number
79
- modId: number,
80
- fieldData: any
81
- }
82
-
83
- class database {
84
- external: boolean;
85
- public name: string;
86
-
87
- protected host: FileMakerConnection;
88
- protected props: databaseOptions;
89
-
90
- public hostname: string;
91
-
92
- constructor(props: databaseOptions, host: FileMakerConnection = null) {
93
- this.props = props
94
- this.name = this.props.database
95
- if (host) this.host = host
96
- }
97
-
98
- get token() {
99
- return this.host.token
100
- }
101
-
102
- getLayout(name): layout {
103
- return new layout(this, name)
104
- }
105
-
106
- async apiRequest(url: string | Request, options: any = {}): Promise<any> {
107
- if (!options.headers) options.headers = {}
108
- options.headers["content-type"] = options.headers["content-type"] ? options.headers["content-type"] : "application/json"
109
- options.headers["authorization"] = "Bearer " + this.host.token
110
- options.rejectUnauthorized = this.host.rejectUnauthroized
111
-
112
- let _fetch = await fetch(url, options)
113
- let data = await _fetch.json()
114
- return (data as any)
115
- }
116
-
117
- setGlobals(globalFields): Promise<void> {
118
- // console.log({globalFields})
119
- return new Promise((resolve, reject) => {
120
- this.apiRequest(`${this.endpoint}/globals`, {
121
- method: "PATCH",
122
- body: JSON.stringify({globalFields})
123
- }).then(res => {
124
- // console.log(res)
125
- if (res.messages[0].code === "0") {
126
- resolve()
127
- } else {
128
- reject(new FMError(res.messages[0].code, res.status, res))
129
- }
130
- })
131
- .catch(e => {
132
- reject(e)
133
- })
134
- })
135
- }
136
-
137
- script(name, parameter): Script {
138
- return ({name, parameter} as Script)
139
- }
140
-
141
- get endpoint(): string {
142
- return `https://${this.hostname || this.host.hostname}/fmi/data/v2/databases/${encodeURI(this.name)}`
143
- }
144
-
145
- get externalLoginInfo() {
146
- // Returns an object that can be used when using this database object as an external source
147
- let out = {
148
- database: this.name,
149
- username: undefined,
150
- password: undefined,
151
- oauthRequestId: undefined,
152
- oauthIdentifier: undefined
153
- }
154
- switch (this.props.method) {
155
- case "filemaker":
156
- out.username = (<loginOptionsFileMaker>this.props).username
157
- out.password = (<loginOptionsFileMaker>this.props).password
158
- case "token":
159
- throw "Token logins cannot be used for connecting to external sources. Please either use no login method, or open a second connection."
160
- case "oauth":
161
- out.oauthRequestId = (<loginOptionsOAuth>this.props).oauth.requestId
162
- out.oauthIdentifier = (<loginOptionsOAuth>this.props).oauth.requestIdentifier
163
-
164
- case "claris":
165
- throw "Claris logins cannot be used for connecting to external sources. Please either use no login method, or open a second connection."
166
- }
167
- return out
168
- }
169
- }
170
-
171
- export default class FileMakerConnection extends database{
172
- private _token: any;
173
- private username: any;
174
- private password: any;
175
- public readonly rejectUnauthroized: boolean;
176
- externalSources: database[];
177
-
178
- constructor(conn: hostConnectionOptions, externalDataSources:databaseOptions[] = [], rejectUnauthorized = true) {
179
- super(conn.database);
180
- this.host = this
181
- this.hostname = conn.hostname
182
- this.externalSources = externalDataSources.map(i => {let e = new database(i, this); e.external = true; return e;})
183
- this.rejectUnauthroized = rejectUnauthorized
184
- }
185
-
186
- logout(): Promise<void> {
187
- return new Promise(async (resolve, reject) => {
188
- if (!this.token) reject(new Error("Not logged in"))
189
-
190
- let _fetch = await fetch(`${this.endpoint}/sessions/${this.token}`, {
191
- method: "DELETE",
192
- headers: {
193
- "content-type": "application/json"
194
- }
195
- })
196
- let data = await _fetch.json()
197
- // console.log(data)
198
- this._token = null
199
- this.name = null
200
- this.hostname = null
201
- resolve()
202
- })
203
- }
204
-
205
- login() {
206
- return new Promise<string>((resolve, reject) => {
207
- if (this.token) throw new Error("Already logged in. Run logout() first")
208
-
209
- if (this.props.method === "filemaker") {
210
- this.username = (<loginOptionsFileMaker>this.props).username
211
- this.name = this.props.database
212
-
213
- fetch(`${this.endpoint}/sessions`, {
214
- hostname: this.hostname,
215
- port: 443,
216
- method: "POST",
217
- headers: {
218
- "Content-Type": "application/json",
219
- "Authorization": "Basic " + Buffer.from(this.username + ":" + (<loginOptionsFileMaker>this.props).password).toString("base64")
220
- }
221
- }).then(async res => {
222
- if (res.status === 200) {
223
- this._token = res.headers.get('x-fm-data-access-token')
224
- resolve(this._token)
225
- } else {
226
- let _res = <any>(await res.json())
227
- reject(new FMError(_res.messages[0].code, _res.status, res))
228
- }
229
- })
230
- .catch(e => {
231
- reject(e)
232
- })
233
- }
234
- else if (this.props.method === "token") {
235
- this._token = (<loginOptionsToken>this.props).token
236
- this.name = this.props.database
237
- resolve(this.token)
238
- }
239
- else if (this.props.method === "oauth") {
240
- this.name = this.props.database
241
- fetch(`${this.endpoint}/sessions`, {
242
- method: "POST",
243
- headers: {
244
- "Content-Type": "application/json",
245
- "X-FM-Data-OAuth-RequestId": (<loginOptionsOAuth>this.props).oauth.requestId,
246
- "X-FM-Data-OAuth-Identifier": (<loginOptionsOAuth>this.props).oauth.requestIdentifier
247
- },
248
- body: "{}"
249
- })
250
- .then(res => res.json())
251
- .then(res => {
252
- let _res = <any>res
253
- this._token = _res.headers["x-fm-data-access-token"]
254
- resolve(this.token)
255
- })
256
- }
257
- else if (this.props.method === "claris") {
258
- this.name = this.props.database
259
- fetch(`${this.endpoint}/sessions`, {
260
- method: "POST",
261
- headers: {
262
- "Content-Type": "application/json",
263
- "Authorization": (<loginOptionsClaris>this.props).claris.fmid,
264
- },
265
- body: "{}"
266
- })
267
- .then(res => res.json())
268
- .then(res => {
269
- let _res = <any>res
270
- this._token = _res.headers["x-fm-data-access-token"]
271
- resolve(this.token)
272
- })
273
- }
274
- })
275
- }
276
-
277
- get token() {
278
- return this._token
279
- }
280
- }
281
-
282
- class layout {
283
- readonly database: database;
284
- protected name: string;
285
- metadata: any;
286
- constructor(database: database, name: string) {
287
- this.database = database
288
- this.name = name
289
- }
290
-
291
- get endpoint() {
292
- return `${this.database.endpoint}/layouts/${this.name}`
293
- }
294
-
295
- getScript(script) {
296
- return new script(this, script)
297
- }
298
-
299
- createRecord(): Promise<record | Error | void> {
300
- return new Promise((resolve, reject) => {
301
- // Get the layout's metadata
302
- this.getLayoutMeta().then(layout => {
303
- let fields = {}
304
- for (let _field of this.metadata.fieldMetaData) {
305
- fields[_field.name] = ""
306
- }
307
- resolve(new record(this, -1, 0, fields))
308
- })
309
- })
310
- }
311
-
312
- getRecord(recordId): record {
313
- return new record(this, recordId)
314
- }
315
-
316
- newFind(): find {
317
- return new find(this)
318
- }
319
-
320
- runScript(script): Promise<string | FMError | Error> {
321
- return new Promise(async (resolve, reject) => {
322
- let url = `${this.endpoint}/script/${encodeURI(script.name)}`
323
- if (script.parameter) url += "?" + encodeURI(script.parameter)
324
- this.database.apiRequest(url, {
325
- port: 443,
326
- method: "GET"
327
- })
328
- .then(res => {
329
- if (res.messages[0].code === "0") {
330
- resolve(res.response)
331
- } else {
332
- // console.log(res)
333
- reject(new FMError(res.messages[0].code, res.status, res))
334
- }
335
- })
336
- .catch(e => {
337
- reject(e)
338
- })
339
- })
340
- }
341
-
342
- public getLayoutMeta(): Promise<layout | FMError> {
343
- return new Promise((resolve, reject) => {
344
- this.database.apiRequest(this.endpoint).then(res => {
345
- this.metadata = res.response
346
- resolve(this)
347
- })
348
- .catch(e => reject(e))
349
- })
350
- }
351
- }
352
-
353
- class record extends EventEmitter {
354
- readonly layout: layout;
355
- public recordId: number;
356
- modId: number;
357
- fields: field[];
358
- readonly portals: portal[];
359
- private portalData: any[];
360
-
361
- constructor(layout, recordId, modId = recordId, fieldData = {}, portalData = null) {
362
- super();
363
- this.layout = layout
364
- this.recordId = recordId
365
- this.modId = modId
366
- this.fields = this.processFieldData(fieldData)
367
- this.portals = []
368
- if (portalData) {
369
- this.processPortalData(portalData)
370
- }
371
- this.on("saved", () => {
372
- for (let field of this.fields) field.edited = false
373
- })
374
- }
375
-
376
- get endpoint(): string {
377
- return `${this.layout.endpoint}/records/${this.recordId}`
378
- }
379
-
380
- get edited(): boolean {
381
- return !!this.fields.find(i => i.edited)
382
- }
383
-
384
- private processPortalData(portalData): void {
385
- for (let item of Object.keys(portalData)) {
386
- this.portals.push(new portal(this, item, portalData[item].map(item => {
387
- let fieldData = item
388
- delete fieldData.recordId
389
- delete fieldData.modId
390
- return new portalItem(this, item.recordId, item.modId, fieldData)
391
- })))
392
- }
393
- }
394
-
395
- processFieldData(fieldData): field[] {
396
- return Object.keys(fieldData).map(item => {
397
- return new field(this, item, fieldData[item])
398
- })
399
- }
400
-
401
- get() {
402
- if (this.recordId === -1) {throw "Cannot get this record until a commit() is done."}
403
- return new Promise(async (resolve, reject) => {
404
- if (!this.layout.metadata) await this.layout.getLayoutMeta()
405
-
406
- this.layout.database.apiRequest(this.endpoint, {
407
- port: 443,
408
- method: "GET"
409
- })
410
- .then(res => {
411
- if (res.messages[0].code === "0") {
412
- // console.log(res, res.response.data)
413
- this.modId = res.response.data[0].modId
414
- this.fields = this.processFieldData(res.response.data[0].fieldData)
415
- this.portalData = []
416
- if (res.response.data[0].portalData) this.processPortalData(res.response.data[0].portalData)
417
- resolve(this)
418
- } else {
419
- reject(new FMError(res.messages[0].code, res.status, res))
420
- }
421
- })
422
- .catch(e => {
423
- reject(e)
424
- })
425
- })
426
- }
427
-
428
- commit(extraBody = {
429
- fieldData: undefined
430
- }): Promise<record | FMError> {
431
- return new Promise(async (resolve, reject) => {
432
- let data = this.toObject()
433
- delete data.recordId
434
- delete data.modId
435
-
436
- if (this.recordId === -1) {
437
- // This is a new record
438
- extraBody.fieldData = data.fieldData
439
-
440
- this.layout.database.apiRequest(`${this.layout.endpoint}/records`, {
441
- port: 443,
442
- method: "POST",
443
- body: JSON.stringify(extraBody)
444
- })
445
- .then(res => {
446
- if (res.messages[0].code === "0") {
447
- this.recordId = parseInt(res.response.recordId)
448
- this.modId = parseInt(res.response.modId)
449
- resolve(this)
450
- } else {
451
- reject(new FMError(res.messages[0].code, res.status, res))
452
- }
453
- })
454
- .catch(e => {
455
- reject(e)
456
- })
457
-
458
- return
459
- }
460
-
461
- for (let item of Object.keys(extraBody)) data[item] = extraBody[item]
462
- this.layout.database.apiRequest(this.endpoint, {
463
- port: 443,
464
- method: "PATCH",
465
- body: JSON.stringify(data)
466
- })
467
- .then(res => {
468
- if (res.messages[0].code === "0") {
469
- this.modId = res.response.modId
470
- this.emit("saved")
471
- resolve(this)
472
- } else {
473
- reject(new FMError(res.messages[0].code, res.status, res))
474
- }
475
- })
476
- .catch(e => {
477
- reject(e)
478
- })
479
- })
480
- }
481
-
482
- getField(field) {
483
- return this.fields.find(_field => _field.id === field)
484
- }
485
-
486
- getPortal(portal) {
487
- return this.portals.find(p => p.name === portal)
488
- }
489
-
490
- toObject(filter = (a) => a.edited,
491
- portalFilter = (a) => a.records.find(record => record.edited),
492
- portalRowFilter = (a) => a.edited,
493
- portalFieldFilter = (a) => a.edited
494
- ): recordObject {
495
- let fields_processed = {}
496
- for (let field of this.fields.filter(field => filter(field))) {
497
- fields_processed[field.id] = field.value
498
- }
499
- let obj = {
500
- "recordId": this.recordId,
501
- "modId": this.modId,
502
- "fieldData": fields_processed
503
- }
504
-
505
- // Check if there's been any edited portal information
506
- let portals = this.portals.filter(a => portalFilter(a))
507
- if (portals) {
508
- obj["portalData"] = {}
509
- for (let portal of portals) {
510
- obj["portalData"][portal.name] = portal.records.filter(a => portalRowFilter(a)).map(record => {
511
- return record.toObject(portalFieldFilter)
512
- })
513
- }
514
- }
515
- return obj
516
- }
517
- }
518
-
519
- class field {
520
- record: record;
521
- id: number;
522
- value: string | number;
523
- edited: boolean;
524
- constructor(record, id, contents) {
525
- this.record = record
526
- this.id = id
527
- this.value = contents
528
- this.edited = false
529
- }
530
-
531
- set(content) {
532
- if (this.dataType === "container") throw "Cannot set container value using set(). Use upload() instead."
533
- this.value = content
534
- this.edited = true
535
- }
536
-
537
- get dataType() {
538
- if (!this.record.layout.metadata) return "unknown"
539
- if (this.record instanceof portalItem) {
540
- return this.record.layout.metadata.portalMetaData.find(i => i.name === this.id).result || "unknown"
541
- } else {
542
- return this.record.layout.metadata.fieldMetaData.find(i => i.name === this.id).result || "unknown"
543
- }
544
- }
545
-
546
- upload(buffer: Buffer, filename: string, mime: string): Promise<void | FMError> {
547
- if (this.dataType !== "container") throw "Cannot upload a file to the field; " + this.id + " (not a container field)"
548
- return new Promise(async (resolve, reject) => {
549
- let form = new FormData()
550
- form.append("upload", new File([buffer], filename, {type: mime}))
551
-
552
- let _fetch = await fetch(`${this.record.endpoint}/containers/${this.id}/1`, {
553
- method: "POST",
554
- headers: {"Authorization": "Bearer " + this.record.layout.database.token},
555
- body: form
556
- }).then(res => res.json())
557
- .then(data => {
558
- let _res = data as any
559
- if (_res.messages[0].code === "0") {
560
- resolve()
561
- } else {
562
- reject(new FMError(_res.messages[0].code, _res.status, data))
563
- }
564
- })
565
- .catch(e => {reject(e)})
566
- })
567
- }
568
- download() {
569
- return new Promise((resolve, reject) => {
570
- https.get(this.value.toString(), (res) => {
571
- resolve(res)
572
- })
573
- })
574
- }
575
- }
576
-
577
- class portal {
578
- readonly record: record;
579
- readonly name: string;
580
- public records: portalItem[];
581
-
582
- constructor(record: record, name: string, records: portalItem[] = []) {
583
- this.record = record
584
- this.name = name
585
- this.records = records
586
- for (let item of this.records) item.attachPortal(this)
587
- }
588
- }
589
-
590
- class portalItem extends record {
591
- protected portal: portal;
592
-
593
- constructor(record, recordId, modId = recordId, fieldData = {}) {
594
- super(record.layout, recordId, modId, fieldData);
595
- record.on("saved", () => {
596
- this.emit("saved")
597
- })
598
- }
599
-
600
- attachPortal(portal) {
601
- this.portal = portal
602
- }
603
-
604
- commit(extraBody = {fieldData: undefined}) {
605
- return this.portal.record.commit(extraBody)
606
- }
607
-
608
- toObject(fieldFilter): any {
609
- super.toObject()
610
- let res = {
611
- recordId: this.recordId, modId: this.modId
612
- } as recordObject
613
- for (let field of this.fields.filter(a => fieldFilter(a))) res[field.id] = field.value
614
- // console.log(res)
615
- return res
616
- }
617
- }
618
-
619
- class find {
620
- protected layout: layout
621
- protected queries: object[]
622
- protected scripts: object
623
- protected sort: object[]
624
-
625
- constructor(layout) {
626
- this.layout = layout
627
- this.queries = []
628
- this.scripts = {
629
- "script": null, // Runs after everything
630
- "script.prerequeset": null, // Runs before the request
631
- "script.presort": null // Runs before the sort
632
- }
633
- this.sort = []
634
- }
635
-
636
- private toObject() {
637
- let out = {query: this.queries, sort: undefined}
638
- if (this.sort.length !== 0) out.sort = this.sort
639
-
640
- for (let item of Object.keys(this.scripts)) {
641
- if (this.scripts[item]) {
642
- out[item] = this.scripts[item].name
643
- if (this.scripts[item].parameter) out[item + ".param"] = this.scripts[item].parameter
644
- }
645
- }
646
-
647
- return out
648
- }
649
-
650
- addRequests(...requests) {
651
- for (let item of requests) this.queries.push(item)
652
- return this
653
- }
654
-
655
- addSort(fieldName, sortOrder) {
656
- this.sort.push({fieldName, sortOrder})
657
- return this
658
- }
659
-
660
- find() {
661
- return new Promise((resolve, reject) => {
662
- // console.log(this.#toObject())
663
- this.layout.database.apiRequest(`${this.layout.endpoint}/_find`, {
664
- port: 443,
665
- method: "POST",
666
- body: JSON.stringify(this.toObject())
667
- }).then(async res => {
668
- // // console.log(res)
669
- if (res.messages[0].code === "0") {
670
- // console.log("RESOLVING")
671
- if (!this.layout.metadata) await this.layout.getLayoutMeta()
672
- let data = res.response.data.map(item => {
673
- return new record(this.layout, item.recordId, item.modId, item.fieldData, item.portalData)
674
- })
675
- resolve(data)
676
- } else {
677
- reject(new FMError(res.messages[0].code, res.status, res))
678
- }
679
- })
680
- .catch(e => {
681
- reject(e)
682
- })
683
- })
684
- }
685
- }
686
-
687
- export class FMError extends Error {
688
- readonly httpStatus: number
689
- readonly res: any
690
- readonly code: number
691
- readonly message: string
692
- readonly messages: string
693
-
694
- constructor(code, httpStatus, res) {
695
- if (typeof code === "string") code = parseInt(code)
696
- super(errs.find(err => err.e === code).d || "Unknown error");
697
- this.httpStatus = httpStatus
698
- this.res = res
699
- this.messages = res.messages
700
- this.code = typeof code === "string" ? parseInt(code) : code
701
- Error.captureStackTrace(this, FMError)
702
- }
703
- }
704
-
705
- //
706
- // module.exports = {
707
- // default: {FileMakerConnection}
1
+ /*
2
+ * Copyright (c) 2022-2023. See LICENSE file for more information
3
+ */
4
+
5
+ // const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
6
+ import fetch, {
7
+ Blob,
8
+ blobFrom,
9
+ blobFromSync,
10
+ File,
11
+ fileFrom,
12
+ fileFromSync,
13
+ FormData,
14
+ Headers,
15
+ Request,
16
+ Response
17
+ } from 'node-fetch';
18
+ import {EventEmitter} from "events";
19
+ // @ts-ignore
20
+ import fs from "fs"
21
+ // @ts-ignore
22
+ import path from "path"
23
+ // @ts-ignore
24
+ import {fileURLToPath} from "url"
25
+ import * as https from "https";
26
+
27
+ // import * as btoa from "btoa";
28
+
29
+ interface connectionOptions {
30
+ hostname: string | undefined,
31
+ database: databaseOptions
32
+ }
33
+
34
+ interface hostConnectionOptions {
35
+ hostname: string
36
+ database: loginOptionsOAuth | loginOptionsFileMaker | loginOptionsClaris | loginOptionsToken
37
+ }
38
+
39
+ interface databaseOptions {
40
+ method: any
41
+ database: string
42
+ }
43
+
44
+ interface loginOptionsToken extends databaseOptions {
45
+ method: "token"
46
+ token: string
47
+ }
48
+
49
+ interface loginOptionsOAuth extends databaseOptions {
50
+ method: "oauth"
51
+ oauth: {
52
+ requestId: string,
53
+ requestIdentifier: string
54
+ }
55
+ }
56
+
57
+ interface loginOptionsFileMaker extends databaseOptions {
58
+ method: "filemaker"
59
+ username: string,
60
+ password: string,
61
+ }
62
+
63
+ interface loginOptionsClaris extends databaseOptions {
64
+ method: "claris"
65
+ claris: {
66
+ fmid: string
67
+ }
68
+ }
69
+
70
+ interface limitPortalsInterface {
71
+ portal: portal,
72
+ offset: number,
73
+ limit: number
74
+ }
75
+
76
+ // @ts-ignore
77
+ const errs = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'fmErrors.json')).toString())
78
+
79
+ interface Script {
80
+ name: string
81
+ parameter: string
82
+ }
83
+
84
+ interface recordObject {
85
+ recordId: number
86
+ modId: number,
87
+ fieldData: any
88
+ }
89
+
90
+ class database {
91
+ external: boolean;
92
+ public name: string;
93
+
94
+ protected host: FileMakerConnection;
95
+ protected props: databaseOptions;
96
+
97
+ public hostname: string;
98
+
99
+ constructor(props: databaseOptions, host: FileMakerConnection = null) {
100
+ this.props = props
101
+ this.name = this.props.database
102
+ if (host) this.host = host
103
+ }
104
+
105
+ get token() {
106
+ return this.host.token
107
+ }
108
+
109
+ getLayout(name): layout {
110
+ return new layout(this, name)
111
+ }
112
+
113
+ async apiRequest(url: string | Request, options: any = {}): Promise<any> {
114
+ if (!options.headers) options.headers = {}
115
+ options.headers["content-type"] = options.headers["content-type"] ? options.headers["content-type"] : "application/json"
116
+ options.headers["authorization"] = "Bearer " + this.host.token
117
+ options.rejectUnauthorized = this.host.rejectUnauthroized
118
+
119
+ let _fetch = await fetch(url, options)
120
+ let data = await _fetch.json()
121
+ return (data as any)
122
+ }
123
+
124
+ setGlobals(globalFields): Promise<void> {
125
+ // console.log({globalFields})
126
+ return new Promise((resolve, reject) => {
127
+ this.apiRequest(`${this.endpoint}/globals`, {
128
+ method: "PATCH",
129
+ body: JSON.stringify({globalFields})
130
+ }).then(res => {
131
+ // console.log(res)
132
+ if (res.messages[0].code === "0") {
133
+ resolve()
134
+ } else {
135
+ reject(new FMError(res.messages[0].code, res.status, res))
136
+ }
137
+ })
138
+ .catch(e => {
139
+ reject(e)
140
+ })
141
+ })
142
+ }
143
+
144
+ script(name, parameter): Script {
145
+ return ({name, parameter} as Script)
146
+ }
147
+
148
+ get endpoint(): string {
149
+ return `https://${this.hostname || this.host.hostname}/fmi/data/v2/databases/${encodeURI(this.name)}`
150
+ }
151
+
152
+ get externalLoginInfo() {
153
+ // Returns an object that can be used when using this database object as an external source
154
+ let out = {
155
+ database: this.name,
156
+ username: undefined,
157
+ password: undefined,
158
+ oauthRequestId: undefined,
159
+ oauthIdentifier: undefined
160
+ }
161
+ switch (this.props.method) {
162
+ case "filemaker":
163
+ out.username = (<loginOptionsFileMaker>this.props).username
164
+ out.password = (<loginOptionsFileMaker>this.props).password
165
+ case "token":
166
+ throw "Token logins cannot be used for connecting to external sources. Please either use no login method, or open a second connection."
167
+ case "oauth":
168
+ out.oauthRequestId = (<loginOptionsOAuth>this.props).oauth.requestId
169
+ out.oauthIdentifier = (<loginOptionsOAuth>this.props).oauth.requestIdentifier
170
+
171
+ case "claris":
172
+ throw "Claris logins cannot be used for connecting to external sources. Please either use no login method, or open a second connection."
173
+ }
174
+ return out
175
+ }
176
+ }
177
+
178
+ export default class FileMakerConnection extends database {
179
+ private _token: any;
180
+ private username: any;
181
+ private password: any;
182
+ public readonly rejectUnauthroized: boolean;
183
+ externalSources: database[];
184
+
185
+ constructor(conn: hostConnectionOptions, externalDataSources: databaseOptions[] = [], rejectUnauthorized = true) {
186
+ super(conn.database);
187
+ this.host = this
188
+ this.hostname = conn.hostname
189
+ this.externalSources = externalDataSources.map(i => {
190
+ let e = new database(i, this);
191
+ e.external = true;
192
+ return e;
193
+ })
194
+ this.rejectUnauthroized = rejectUnauthorized
195
+ }
196
+
197
+ logout(): Promise<void> {
198
+ return new Promise(async (resolve, reject) => {
199
+ if (!this.token) reject(new Error("Not logged in"))
200
+
201
+ let _fetch = await fetch(`${this.endpoint}/sessions/${this.token}`, {
202
+ method: "DELETE",
203
+ headers: {
204
+ "content-type": "application/json"
205
+ }
206
+ })
207
+ let data = await _fetch.json()
208
+ // console.log(data)
209
+ this._token = null
210
+ this.name = null
211
+ this.hostname = null
212
+ resolve()
213
+ })
214
+ }
215
+
216
+ login() {
217
+ return new Promise<string>((resolve, reject) => {
218
+ if (this.token) throw new Error("Already logged in. Run logout() first")
219
+
220
+ if (this.props.method === "filemaker") {
221
+ this.username = (<loginOptionsFileMaker>this.props).username
222
+ this.name = this.props.database
223
+
224
+ fetch(`${this.endpoint}/sessions`, {
225
+ hostname: this.hostname,
226
+ port: 443,
227
+ method: "POST",
228
+ headers: {
229
+ "Content-Type": "application/json",
230
+ "Authorization": "Basic " + Buffer.from(this.username + ":" + (<loginOptionsFileMaker>this.props).password).toString("base64")
231
+ }
232
+ }).then(async res => {
233
+ if (res.status === 200) {
234
+ this._token = res.headers.get('x-fm-data-access-token')
235
+ resolve(this._token)
236
+ } else {
237
+ let _res = <any>(await res.json())
238
+ reject(new FMError(_res.messages[0].code, _res.status, res))
239
+ }
240
+ })
241
+ .catch(e => {
242
+ reject(e)
243
+ })
244
+ } else if (this.props.method === "token") {
245
+ this._token = (<loginOptionsToken>this.props).token
246
+ this.name = this.props.database
247
+ resolve(this.token)
248
+ } else if (this.props.method === "oauth") {
249
+ this.name = this.props.database
250
+ fetch(`${this.endpoint}/sessions`, {
251
+ method: "POST",
252
+ headers: {
253
+ "Content-Type": "application/json",
254
+ "X-FM-Data-OAuth-RequestId": (<loginOptionsOAuth>this.props).oauth.requestId,
255
+ "X-FM-Data-OAuth-Identifier": (<loginOptionsOAuth>this.props).oauth.requestIdentifier
256
+ },
257
+ body: "{}"
258
+ })
259
+ .then(res => res.json())
260
+ .then(res => {
261
+ let _res = <any>res
262
+ this._token = _res.headers["x-fm-data-access-token"]
263
+ resolve(this.token)
264
+ })
265
+ } else if (this.props.method === "claris") {
266
+ this.name = this.props.database
267
+ fetch(`${this.endpoint}/sessions`, {
268
+ method: "POST",
269
+ headers: {
270
+ "Content-Type": "application/json",
271
+ "Authorization": (<loginOptionsClaris>this.props).claris.fmid,
272
+ },
273
+ body: "{}"
274
+ })
275
+ .then(res => res.json())
276
+ .then(res => {
277
+ let _res = <any>res
278
+ this._token = _res.headers["x-fm-data-access-token"]
279
+ resolve(this.token)
280
+ })
281
+ }
282
+ })
283
+ }
284
+
285
+ get token() {
286
+ return this._token
287
+ }
288
+ }
289
+
290
+ class layout {
291
+ readonly database: database;
292
+ protected name: string;
293
+ metadata: any;
294
+
295
+ constructor(database: database, name: string) {
296
+ this.database = database
297
+ this.name = name
298
+ }
299
+
300
+ get endpoint() {
301
+ return `${this.database.endpoint}/layouts/${this.name}`
302
+ }
303
+
304
+ getScript(script) {
305
+ return new script(this, script)
306
+ }
307
+
308
+ createRecord(): Promise<record | Error | void> {
309
+ return new Promise((resolve, reject) => {
310
+ // Get the layout's metadata
311
+ this.getLayoutMeta().then(layout => {
312
+ let fields = {}
313
+ for (let _field of this.metadata.fieldMetaData) {
314
+ fields[_field.name] = ""
315
+ }
316
+ resolve(new record(this, -1, 0, fields))
317
+ })
318
+ })
319
+ }
320
+
321
+ getRecord(recordId): record {
322
+ return new record(this, recordId)
323
+ }
324
+
325
+ newFind(): find {
326
+ return new find(this)
327
+ }
328
+
329
+ runScript(script): Promise<string | FMError | Error> {
330
+ return new Promise(async (resolve, reject) => {
331
+ let url = `${this.endpoint}/script/${encodeURI(script.name)}`
332
+ if (script.parameter) url += "?" + encodeURI(script.parameter)
333
+ this.database.apiRequest(url, {
334
+ port: 443,
335
+ method: "GET"
336
+ })
337
+ .then(res => {
338
+ if (res.messages[0].code === "0") {
339
+ resolve(res.response)
340
+ } else {
341
+ // console.log(res)
342
+ reject(new FMError(res.messages[0].code, res.status, res))
343
+ }
344
+ })
345
+ .catch(e => {
346
+ reject(e)
347
+ })
348
+ })
349
+ }
350
+
351
+ public getLayoutMeta(): Promise<layout | FMError> {
352
+ return new Promise((resolve, reject) => {
353
+ this.database.apiRequest(this.endpoint).then(res => {
354
+ this.metadata = res.response
355
+ resolve(this)
356
+ })
357
+ .catch(e => reject(e))
358
+ })
359
+ }
360
+ }
361
+
362
+ class record extends EventEmitter {
363
+ readonly layout: layout;
364
+ public recordId: number;
365
+ modId: number;
366
+ fields: field[];
367
+ readonly portals: portal[];
368
+ private portalData: any[];
369
+
370
+ constructor(layout, recordId, modId = recordId, fieldData = {}, portalData = null) {
371
+ super();
372
+ this.layout = layout
373
+ this.recordId = recordId
374
+ this.modId = modId
375
+ this.fields = this.processFieldData(fieldData)
376
+ this.portals = []
377
+ if (portalData) {
378
+ this.processPortalData(portalData)
379
+ }
380
+ this.on("saved", () => {
381
+ for (let field of this.fields) field.edited = false
382
+ })
383
+ }
384
+
385
+ get endpoint(): string {
386
+ return `${this.layout.endpoint}/records/${this.recordId}`
387
+ }
388
+
389
+ get edited(): boolean {
390
+ return !!this.fields.find(i => i.edited)
391
+ }
392
+
393
+ private processPortalData(portalData): void {
394
+ for (let item of Object.keys(portalData)) {
395
+ this.portals.push(new portal(this, item, portalData[item].map(item => {
396
+ let fieldData = item
397
+ delete fieldData.recordId
398
+ delete fieldData.modId
399
+ return new portalItem(this, item.recordId, item.modId, fieldData)
400
+ })))
401
+ }
402
+ }
403
+
404
+ processFieldData(fieldData): field[] {
405
+ return Object.keys(fieldData).map(item => {
406
+ return new field(this, item, fieldData[item])
407
+ })
408
+ }
409
+
410
+ get() {
411
+ if (this.recordId === -1) {
412
+ throw "Cannot get this record until a commit() is done."
413
+ }
414
+ return new Promise(async (resolve, reject) => {
415
+ if (!this.layout.metadata) await this.layout.getLayoutMeta()
416
+
417
+ this.layout.database.apiRequest(this.endpoint, {
418
+ port: 443,
419
+ method: "GET"
420
+ })
421
+ .then(res => {
422
+ if (res.messages[0].code === "0") {
423
+ // console.log(res, res.response.data)
424
+ this.modId = res.response.data[0].modId
425
+ this.fields = this.processFieldData(res.response.data[0].fieldData)
426
+ this.portalData = []
427
+ if (res.response.data[0].portalData) this.processPortalData(res.response.data[0].portalData)
428
+ resolve(this)
429
+ } else {
430
+ reject(new FMError(res.messages[0].code, res.status, res))
431
+ }
432
+ })
433
+ .catch(e => {
434
+ reject(e)
435
+ })
436
+ })
437
+ }
438
+
439
+ commit(extraBody = {
440
+ fieldData: undefined
441
+ }): Promise<record | FMError> {
442
+ return new Promise(async (resolve, reject) => {
443
+ let data = this.toObject()
444
+ delete data.recordId
445
+ delete data.modId
446
+
447
+ if (this.recordId === -1) {
448
+ // This is a new record
449
+ extraBody.fieldData = data.fieldData
450
+
451
+ this.layout.database.apiRequest(`${this.layout.endpoint}/records`, {
452
+ port: 443,
453
+ method: "POST",
454
+ body: JSON.stringify(extraBody)
455
+ })
456
+ .then(res => {
457
+ if (typeof res.response.scriptError !== "undefined" && res.response.scriptError !== '0') {
458
+ reject(new FMError(res.response.scriptError, res.status, res))
459
+ } else if (res.messages[0].code === "0") {
460
+ console.log(res)
461
+ this.recordId = parseInt(res.response.recordId)
462
+ this.modId = parseInt(res.response.modId)
463
+ resolve(this)
464
+ } else {
465
+ reject(new FMError(res.messages[0].code, res.status, res))
466
+ }
467
+ })
468
+ .catch(e => {
469
+ reject(e)
470
+ })
471
+
472
+ return
473
+ }
474
+
475
+ for (let item of Object.keys(extraBody)) data[item] = extraBody[item]
476
+ this.layout.database.apiRequest(this.endpoint, {
477
+ port: 443,
478
+ method: "PATCH",
479
+ body: JSON.stringify(data)
480
+ })
481
+ .then(res => {
482
+ if (typeof res.response.scriptError !== "undefined" && res.response.scriptError !== '0') {
483
+ reject(new FMError(res.response.scriptError, res.status, res))
484
+ } else if (res.messages[0].code === "0") {
485
+ console.log(res)
486
+ this.modId = res.response.modId
487
+ this.emit("saved")
488
+ resolve(this)
489
+ } else {
490
+ reject(new FMError(res.messages[0].code, res.status, res))
491
+ }
492
+ })
493
+ .catch(e => {
494
+ reject(e)
495
+ })
496
+ })
497
+ }
498
+
499
+ getField(field) {
500
+ return this.fields.find(_field => _field.id === field)
501
+ }
502
+
503
+ getPortal(portal) {
504
+ return this.portals.find(p => p.name === portal)
505
+ }
506
+
507
+ toObject(filter = (a) => a.edited,
508
+ portalFilter = (a) => a.records.find(record => record.edited),
509
+ portalRowFilter = (a) => a.edited,
510
+ portalFieldFilter = (a) => a.edited
511
+ ): recordObject {
512
+ let fields_processed = {}
513
+ for (let field of this.fields.filter(field => filter(field))) {
514
+ fields_processed[field.id] = field.value
515
+ }
516
+ let obj = {
517
+ "recordId": this.recordId,
518
+ "modId": this.modId,
519
+ "fieldData": fields_processed
520
+ }
521
+
522
+ // Check if there's been any edited portal information
523
+ let portals = this.portals.filter(a => portalFilter(a))
524
+ if (portals) {
525
+ obj["portalData"] = {}
526
+ for (let portal of portals) {
527
+ obj["portalData"][portal.name] = portal.records.filter(a => portalRowFilter(a)).map(record => {
528
+ return record.toObject(portalFieldFilter)
529
+ })
530
+ }
531
+ }
532
+ return obj
533
+ }
534
+ }
535
+
536
+ class field {
537
+ record: record;
538
+ id: number;
539
+ value: string | number;
540
+ edited: boolean;
541
+
542
+ constructor(record, id, contents) {
543
+ this.record = record
544
+ this.id = id
545
+ this.value = contents
546
+ this.edited = false
547
+ }
548
+
549
+ set(content) {
550
+ if (this.dataType === "container") throw "Cannot set container value using set(). Use upload() instead."
551
+ this.value = content
552
+ this.edited = true
553
+ }
554
+
555
+ get dataType() {
556
+ if (!this.record.layout.metadata) return "unknown"
557
+ if (this.record instanceof portalItem) {
558
+ return this.record.layout.metadata.portalMetaData.find(i => i.name === this.id).result || "unknown"
559
+ } else {
560
+ return this.record.layout.metadata.fieldMetaData.find(i => i.name === this.id).result || "unknown"
561
+ }
562
+ }
563
+
564
+ upload(buffer: Buffer, filename: string, mime: string): Promise<void | FMError> {
565
+ if (this.dataType !== "container") throw "Cannot upload a file to the field; " + this.id + " (not a container field)"
566
+ return new Promise(async (resolve, reject) => {
567
+ let form = new FormData()
568
+ form.append("upload", new File([buffer], filename, {type: mime}))
569
+
570
+ let _fetch = await fetch(`${this.record.endpoint}/containers/${this.id}/1`, {
571
+ method: "POST",
572
+ headers: {"Authorization": "Bearer " + this.record.layout.database.token},
573
+ body: form
574
+ }).then(res => res.json())
575
+ .then(data => {
576
+ let _res = data as any
577
+ if (_res.messages[0].code === "0") {
578
+ resolve()
579
+ } else {
580
+ reject(new FMError(_res.messages[0].code, _res.status, data))
581
+ }
582
+ })
583
+ .catch(e => {
584
+ reject(e)
585
+ })
586
+ })
587
+ }
588
+
589
+ download() {
590
+ return new Promise((resolve, reject) => {
591
+ https.get(this.value.toString(), (res) => {
592
+ resolve(res)
593
+ })
594
+ })
595
+ }
596
+ }
597
+
598
+ class portal {
599
+ readonly record: record;
600
+ readonly name: string;
601
+ public records: portalItem[];
602
+
603
+ constructor(record: record, name: string, records: portalItem[] = []) {
604
+ this.record = record
605
+ this.name = name
606
+ this.records = records
607
+ for (let item of this.records) item.attachPortal(this)
608
+ }
609
+ }
610
+
611
+ class portalItem extends record {
612
+ protected portal: portal;
613
+
614
+ constructor(record, recordId, modId = recordId, fieldData = {}) {
615
+ super(record.layout, recordId, modId, fieldData);
616
+ record.on("saved", () => {
617
+ this.emit("saved")
618
+ })
619
+ }
620
+
621
+ attachPortal(portal) {
622
+ this.portal = portal
623
+ }
624
+
625
+ commit(extraBody = {fieldData: undefined}) {
626
+ return this.portal.record.commit(extraBody)
627
+ }
628
+
629
+ toObject(fieldFilter): any {
630
+ super.toObject()
631
+ let res = {
632
+ recordId: this.recordId, modId: this.modId
633
+ } as recordObject
634
+ for (let field of this.fields.filter(a => fieldFilter(a))) res[field.id] = field.value
635
+ // console.log(res)
636
+ return res
637
+ }
638
+ }
639
+
640
+ class recordGetOperation {
641
+ protected layout: layout
642
+ protected limit: number = 100
643
+ protected scripts: object
644
+ protected sort: object[]
645
+ protected limitPortals: limitPortalsInterface[] = []
646
+ protected offset: number = 0
647
+
648
+ constructor(layout) {
649
+ this.layout = layout
650
+ this.scripts = {
651
+ "script": null, // Runs after everything
652
+ "script.prerequeset": null, // Runs before the request
653
+ "script.presort": null // Runs before the sort
654
+ }
655
+ this.sort = []
656
+ }
657
+
658
+ /*
659
+ addToPortalLimit() will adjust the results of the get request so that if a layout has multiple portals in it,
660
+ only data from the specified ones will be read. This may help reduce load on your FileMaker API
661
+ */
662
+ addToPortalLimit(portal: portal, offset = 0, limit = 100) {
663
+ if (offset < 0) throw "Portal offset cannot be less than 0"
664
+ this.limitPortals.push({portal, offset, limit})
665
+ }
666
+
667
+ setLimit(limit: number) {
668
+ if (limit < 1) throw "Record limit too low"
669
+ this.limit = limit
670
+ }
671
+
672
+ addSort(fieldName, sortOrder) {
673
+ this.sort.push({fieldName, sortOrder})
674
+ return this
675
+ }
676
+
677
+ setOffset(offset: number) {
678
+ if (offset < 0) throw "Record offset too low"
679
+ this.limit = offset
680
+ }
681
+ }
682
+
683
+ class recordGetRange extends recordGetOperation {
684
+ constructor(layout) {
685
+ super(layout)
686
+ }
687
+
688
+ private generateQueryParams() {
689
+ let params = []
690
+ if (this.limit !== 100) params.push("_limit=" + this.limit)
691
+ if (this.offset !== 0) params.push("_offset=" + this.offset)
692
+ if (this.sort.length > 0) params.push("_sort=" + encodeURI(JSON.stringify(this.sort)))
693
+ if (this.limitPortals.length > 0) {
694
+ params.push("portal=" + encodeURI(JSON.stringify(this.limitPortals.map(p => p.portal.name))))
695
+ for (let item of this.limitPortals) {
696
+ if (item.offset !== 0) params.push("_offset." + item.portal.name.replace(/[^0-9A-z]/g, "") + "=" + item.offset)
697
+ if (item.limit !== 100) params.push("_limit." + item.portal.name.replace(/[^0-9A-z]/g, "") + "=" + item.limit)
698
+ }
699
+ }
700
+
701
+ if (params.length === 0) return ""
702
+ return "?" + params.join("&")
703
+ }
704
+
705
+ run() {
706
+ return new Promise((resolve, reject) => {
707
+ // console.log(this.#toObject())
708
+ this.layout.database.apiRequest(`${this.layout.endpoint}/records${this.generateQueryParams()}`, {
709
+ method: "GET"
710
+ }).then(async res => {
711
+ // // console.log(res)
712
+ if (res.messages[0].code === "0") {
713
+ // console.log("RESOLVING")
714
+ if (!this.layout.metadata) await this.layout.getLayoutMeta()
715
+ let data = res.response.data.map(item => {
716
+ return new record(this.layout, item.recordId, item.modId, item.fieldData, item.portalData)
717
+ })
718
+ resolve(data)
719
+ } else {
720
+ reject(new FMError(res.messages[0].code, res.status, res))
721
+ }
722
+ })
723
+ .catch(e => {
724
+ reject(e)
725
+ })
726
+ })
727
+ }
728
+ }
729
+
730
+ class find extends recordGetOperation {
731
+ protected queries: object[]
732
+
733
+ constructor(layout) {
734
+ super(layout)
735
+ this.queries = []
736
+ }
737
+
738
+ private toObject() {
739
+ let out = {query: this.queries, sort: undefined}
740
+ if (this.sort.length !== 0) out.sort = this.sort
741
+
742
+ for (let item of Object.keys(this.scripts)) {
743
+ if (this.scripts[item]) {
744
+ out[item] = this.scripts[item].name
745
+ if (this.scripts[item].parameter) out[item + ".param"] = this.scripts[item].parameter
746
+ }
747
+ }
748
+
749
+ if (this.limit !== 100) out["limit"] = this.limit
750
+ if (this.offset !== 0) out["offset"] = this.offset
751
+ if (this.limitPortals.length > 0) {
752
+ out["portal"] = this.limitPortals.map(p => p.portal.name)
753
+ for (let item of this.limitPortals) {
754
+ out["offset." + item.portal.name.replace(/[^0-9A-z]/g, "")] = item.offset
755
+ out["limit." + item.portal.name.replace(/[^0-9A-z]/g, "")] = item.limit
756
+ }
757
+ }
758
+
759
+ return out
760
+ }
761
+
762
+ addRequests(...requests) {
763
+ for (let item of requests) this.queries.push(item)
764
+ return this
765
+ }
766
+
767
+ find() {
768
+ return this.run()
769
+ }
770
+
771
+ run() {
772
+ return new Promise((resolve, reject) => {
773
+ // console.log(this.#toObject())
774
+ this.layout.database.apiRequest(`${this.layout.endpoint}/_find`, {
775
+ port: 443,
776
+ method: "POST",
777
+ body: JSON.stringify(this.toObject())
778
+ }).then(async res => {
779
+ // // console.log(res)
780
+ if (res.messages[0].code === "0") {
781
+ // console.log("RESOLVING")
782
+ if (!this.layout.metadata) await this.layout.getLayoutMeta()
783
+ let data = res.response.data.map(item => {
784
+ return new record(this.layout, item.recordId, item.modId, item.fieldData, item.portalData)
785
+ })
786
+ resolve(data)
787
+ } else {
788
+ reject(new FMError(res.messages[0].code, res.status, res))
789
+ }
790
+ })
791
+ .catch(e => {
792
+ reject(e)
793
+ })
794
+ })
795
+ }
796
+ }
797
+
798
+ export class FMError extends Error {
799
+ readonly httpStatus: number
800
+ readonly res: any
801
+ readonly code: number
802
+ readonly message: string
803
+ readonly messages: string
804
+
805
+ constructor(code, httpStatus, res) {
806
+ if (typeof code === "string") code = parseInt(code)
807
+ super(errs.find(err => err.e === code).d || "Unknown error");
808
+ this.httpStatus = httpStatus
809
+ this.res = res
810
+ this.messages = res.messages
811
+ this.code = typeof code === "string" ? parseInt(code) : code
812
+ Error.captureStackTrace(this, FMError)
813
+ }
814
+ }
815
+
816
+ //
817
+ // module.exports = {
818
+ // default: {FileMakerConnection}
708
819
  // }