@mcpher/gas-fakes 1.0.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.
@@ -0,0 +1,568 @@
1
+ import { Proxies } from '../../support/proxies.js'
2
+ import { newPeeker } from '../../support/peeker.js'
3
+ import { Utils } from '../../support/utils.js'
4
+ import {
5
+ handleError,
6
+ decorateParams,
7
+ minFields,
8
+ minFieldsList,
9
+ isFolder,
10
+ throwResponse
11
+ } from './fakedrivehelpers.js'
12
+ import { Syncit } from '../../support/syncit.js'
13
+ import { folderType } from '../../support/constants.js'
14
+
15
+
16
+ /**
17
+ * things are pretty slow on node, especially repeatedly getting parents
18
+ * so we'll cache that over here
19
+ */
20
+ const CACHE_ENABLED = true
21
+ const fileCache = new Map()
22
+ const getFromCache = (id) => {
23
+ if (CACHE_ENABLED) {
24
+ const file = fileCache.get(id)
25
+ if (file) return file
26
+ }
27
+ return null
28
+ }
29
+
30
+ /**
31
+ * create a new drive file instance
32
+ * @param {...any} args
33
+ * @returns {FakeDriveFile}
34
+ */
35
+ const newFakeDriveFile = (...args) => {
36
+ return Proxies.guard(new FakeDriveFile(...args))
37
+ }
38
+
39
+ /**
40
+ * create a new drive folder instance
41
+ * @param {...any} args
42
+ * @returns {FakeDriveFolder}
43
+ */
44
+ const newFakeDriveFolder = (...args) => {
45
+ return Proxies.guard(new FakeDriveFolder(...args))
46
+ }
47
+
48
+ /**
49
+ * create a new drive app instance
50
+ * @param {...any} args
51
+ * @returns {FakeDriveApp}
52
+ */
53
+ export const newFakeDriveApp = (...args) => {
54
+ return Proxies.guard(new FakeDriveApp(...args))
55
+ }
56
+
57
+
58
+ /**
59
+ * list get any kind using the NODE client
60
+ * @param {string} [parentId] the parent id
61
+ * @param {function} [handler = handleError]
62
+ * @param {object|[object]} [qob] any additional queries
63
+ * @param {TODO} [fields] the fields to fetch
64
+ * @param {TODO} [options] mimic fetchapp options
65
+ * @param {string} [pageToken=null] if we're doing a pagetoken kind of thing
66
+ * @param {boolean} folderTypes whether to get foldertypes
67
+ * @param {boolean} fileTypes whether to get fileTypes
68
+ * @returns {object} a collection of files {response, data}
69
+ */
70
+ const fileLister = ({
71
+ qob, parentId, fields, handler, folderTypes, fileTypes, pageToken = null, fileName
72
+ }) => {
73
+ // enhance any already supplied query params
74
+ qob = Utils.arrify(qob) || []
75
+ if (parentId) {
76
+ qob.push(`'${parentId}' in parents`)
77
+ }
78
+
79
+ // wheteher we're getting files,folders or both
80
+ if (!(folderTypes || fileTypes)) {
81
+ throw new Error(`Must specify either folder type,file type or both`)
82
+ }
83
+
84
+ // exclusive xor - if they're both true we dont need to do any extra q filtering
85
+ if (folderTypes !== fileTypes) {
86
+ qob.push(`mimeType${fileTypes ? "!" : ""}='${folderType}'`)
87
+ }
88
+
89
+ const q = qob.map(f => `(${f})`).join(" and ")
90
+ let params = { q }
91
+ if (pageToken) {
92
+ params.pageToken = pageToken
93
+ }
94
+
95
+ params = decorateParams({ fields, params, min: minFieldsList })
96
+
97
+ // this will have be synced from async
98
+ try {
99
+ const result = Syncit.fxDrive({ prop: "files", method: "list", params })
100
+ return result
101
+ } catch (err) {
102
+ handler(err)
103
+ }
104
+
105
+ return result
106
+ }
107
+ /**
108
+ * general getter by id
109
+ * @param {object} p args
110
+ * @param {string} p.id the id to get
111
+ * @param {boolean} [p.allow404=true] normally a 404 doesnt throw an error
112
+ * @returns {FakeDriveFolder}
113
+ */
114
+ const getFileById = ({ id, allow404 = true, fields }) => {
115
+
116
+ // we'll pull this from cache if poss
117
+ const cachedFile = getFromCache(id)
118
+ if (cachedFile && (!fields || Utils.arrify(fields).every(f => Reflect.has(cachedFile, f)))) {
119
+ return cachedFile
120
+ }
121
+
122
+ // it wasnt in cache
123
+ const { file, response } = fetchFile({ id, fields })
124
+ if (!file) {
125
+ if (!allow404) {
126
+ throwResponse(response)
127
+ } else {
128
+ return null
129
+ }
130
+ }
131
+ if (CACHE_ENABLED) {
132
+ fileCache.set(id, file)
133
+ }
134
+ return file
135
+ }
136
+
137
+
138
+ /**
139
+ * shared get any kind of file meta data by its id
140
+ * @param {string} [parentId] the parent id
141
+ * @param {function} [handler = handleError]
142
+ * @param {object[]} [qob] any additional queries
143
+ * @param {object|object[]} [fields] the fields to fetch
144
+ * @param {TODO} [options] mimic fetchapp options
145
+ * @param {boolean} folderTypes whether to get foldertypes
146
+ * @param {boolean} fileTypes whether to get fileTypes
147
+ * @returns {object} {Peeker}
148
+ */
149
+ const getFilesIterator = ({
150
+ qob,
151
+ parentId = null,
152
+ fields = [],
153
+ handler = handleError,
154
+ folderTypes,
155
+ fileTypes
156
+ }) => {
157
+
158
+ // parentId can be null to search everywhere
159
+ if (!Utils.isNull(parentId)) Utils.assertType(parentId, "string")
160
+ Utils.assertType(handler, "function")
161
+ Utils.assertType(fields, "object")
162
+ Utils.assertType(folderTypes, "boolean")
163
+ Utils.assertType(fileTypes, "boolean")
164
+
165
+
166
+ /**
167
+ * this generator will get chunks of matching files from the drive api
168
+ * and yield them 1 by 1 and handle paging if required
169
+ */
170
+ function* filesink() {
171
+ // the result tank
172
+ let tank = []
173
+ // the next page token
174
+ let pageToken = null
175
+
176
+ do {
177
+ // if nothing in the tank, fill it up
178
+ if (!tank.length) {
179
+
180
+ const result = fileLister({
181
+ qob, parentId, fields, handler, folderTypes, fileTypes, pageToken
182
+ })
183
+
184
+ // the presence of a nextPageToken is the signal that there's more to come
185
+ pageToken = result.data.nextPageToken
186
+
187
+ // format the results into the folder or file object
188
+ tank = result.data.files.map(
189
+ f => isFolder(f) ? newFakeDriveFolder(f) : newFakeDriveFile(f)
190
+ )
191
+ }
192
+
193
+ // if we've got anything in the tank send back the oldest one
194
+ if (tank.length) {
195
+ yield tank.splice(0, 1)[0]
196
+ }
197
+ // if there's still anything left in the tank,
198
+ // or there's a page token to get more continue
199
+ } while (pageToken || tank.length)
200
+ }
201
+
202
+ // create the iterator
203
+ const fileit = filesink()
204
+
205
+ // a regular iterator doesnt support the same methods
206
+ // as Apps Script so we'll fake that too
207
+ return newPeeker(fileit)
208
+
209
+ }
210
+
211
+ /**
212
+ * this gets an intertor to fetch all the parents meta data
213
+ * @param {FakeDriveMeta} {file} the meta data
214
+ * @returns {object} {Peeker}
215
+ */
216
+ const getParentsIterator = ({
217
+ file
218
+ }) => {
219
+
220
+ Utils.assertType(file, "object")
221
+ Utils.assertType(file.parents, "array")
222
+
223
+ function* filesink() {
224
+ // the result tank, we just get them all by id
225
+ let tank = file.parents.map(id => getFileById({ id, allow404: false }))
226
+
227
+ while (tank.length) {
228
+ yield newFakeDriveFolder(tank.splice(0, 1)[0])
229
+ }
230
+ }
231
+
232
+ // create the iterator
233
+ const parentsIt = filesink()
234
+
235
+ // a regular iterator doesnt support the same methods
236
+ // as Apps Script so we'll fake that too
237
+ return newPeeker(parentsIt)
238
+
239
+ }
240
+
241
+ /**
242
+ * shared get any kind of file meta data by its id
243
+ * @param {string} id the file id
244
+ * @param {function} [handler = handleError]
245
+ * @param {TODO} [fields] the fields to fetch
246
+ * @returns {object} {File, FakeHttpResponse}
247
+ */
248
+ const fetchFile = ({ id, handler = handleError, fields }) => {
249
+ Utils.assertType(id, "string")
250
+ Utils.assertType(handler, "function")
251
+
252
+ const params = decorateParams({
253
+ fields, min: minFields, params: { fileId: id }
254
+ })
255
+
256
+ // TODO need to handle muteHttpExceptions and any other options
257
+ // use the sync version of the drive gapi api
258
+ const { data, response } = Syncit.fxDrive({ prop: "files", method: "get", params })
259
+ return {
260
+ file: data,
261
+ response
262
+ }
263
+ }
264
+
265
+
266
+ /**
267
+ * a File type returned from the json api with my default fields applied
268
+ * there could be others if custom fields are returned
269
+ * @typedef File
270
+ * @property {string} id the id
271
+ * @property {string} name the name
272
+ * @property {string} mimeType the mimetype
273
+ * @property {string[]} parents ids of parents
274
+ */
275
+
276
+ /**
277
+ * basic fake File meta data
278
+ * @class FakeDriveMeta
279
+ * @returns {FakeDriveMeta}
280
+ */
281
+ export class FakeDriveMeta {
282
+ /**
283
+ *
284
+ * @constructor
285
+ * @param {File} meta data from json api
286
+ * @returns {FakeDriveMeta}
287
+ */
288
+ constructor(meta) {
289
+ this.meta = meta
290
+ }
291
+
292
+
293
+ /**
294
+ * get the file id
295
+ * @returns {string} the file id
296
+ */
297
+ getId() {
298
+ return this.meta.id
299
+ }
300
+
301
+ /**
302
+ * get the file name
303
+ * @returns {string} the file name
304
+ */
305
+ getName() {
306
+ return this.meta.name
307
+ }
308
+
309
+ /**
310
+ * get the file mimetype
311
+ * @returns {string} the file mimetpe
312
+ */
313
+ getMimeType() {
314
+ return this.meta.mimeType
315
+ }
316
+
317
+ /**
318
+ * get the ids of the parents
319
+ * @returns {string[]} the file parents
320
+ */
321
+ getParents() {
322
+ return getParentsIterator({ file: this.meta })
323
+ }
324
+
325
+ /**
326
+ * for enhancing the file with fields not retrieved by default
327
+ * @param {string|string[]} [field=[]] the required fields
328
+ * @return {FakeDriveMeta} self
329
+ */
330
+ decorateWithFields(fields = []) {
331
+ const newMeta = getFileById({ id: this.getId(), fields, allow404: false })
332
+ // need to merge this with already known fields
333
+ this.meta = { ...this.meta, ...newMeta }
334
+ return this
335
+ }
336
+
337
+ /**
338
+ * the meta data for the following fields are not fetched by default
339
+ */
340
+ getDecorated(prop) {
341
+ return this.decorateWithFields(prop).meta[prop]
342
+ }
343
+
344
+
345
+ getSize() {
346
+ // the meta is actually a string so convert
347
+ return parseInt(this.getDecorated("size"), 10)
348
+ }
349
+
350
+ getDateCreated() {
351
+ return new Date(this.getDecorated("createdTime"))
352
+ }
353
+
354
+ getDescription() {
355
+ // the meta can be undefined so return null
356
+ const d = this.getDecorated("description")
357
+ return Utils.isUndefined(d) ? null : d
358
+ }
359
+
360
+ getLastUpdated() {
361
+ return new Date(this.getDecorated("modifiedTime"))
362
+ }
363
+
364
+ isStarred() {
365
+ return this.getDecorated("starred")
366
+ }
367
+
368
+ isTrashed() {
369
+ return this.getDecorated("trashed")
370
+ }
371
+ }
372
+
373
+
374
+ /**
375
+ * basic fake File
376
+ * TODO add lots more methods
377
+ * @class FakeDriveFile
378
+ * @extends FakeDriveMeta
379
+ * @returns {FakeDriveFile}
380
+ */
381
+ export class FakeDriveFile extends FakeDriveMeta {
382
+ /**
383
+ *
384
+ * @constructor
385
+ * @param {File} meta data from json api
386
+ * @returns {FakeDriveFile}
387
+ */
388
+ constructor(meta) {
389
+ super(meta)
390
+ if (isFolder(meta)) {
391
+ throw new Error(`file cant be a folder:` + JSON.stringify(meta))
392
+ }
393
+
394
+ }
395
+ /**
396
+ *
397
+ * @returns {FakeBlob}
398
+ */
399
+ getBlob() {
400
+ // spawn child process to syncify getting content as by array
401
+ const { data } = Syncit.fxDriveMedia({ id: this.getId() })
402
+ // and blobify
403
+ return Utilities.newBlob(data, this.getName(), this.getMimeType())
404
+ }
405
+
406
+ }
407
+
408
+ /**
409
+ * basic fake Folder
410
+ * TODO add lots more methods
411
+ * @class FakeDriveFolder
412
+ * @extends FakeDriveFile
413
+ * @returns {FakeDriveFolder}
414
+ */
415
+ export class FakeDriveFolder extends FakeDriveMeta {
416
+
417
+ /**
418
+ *
419
+ * @constructor
420
+ * @param {File} meta data from json api
421
+ * @returns {FakeDriveFile}
422
+ */
423
+ constructor(meta) {
424
+ super(meta)
425
+ if (!isFolder(meta)) {
426
+ throw new Error(`file must be a folder:` + JSON.stringify(meta))
427
+ }
428
+ }
429
+
430
+ //TODO something wrong with this
431
+ get _isRoot() {
432
+ return Boolean(this.getParents() || !this.getParents().length)
433
+ }
434
+
435
+ /**
436
+ * get files in this folder
437
+ * @return {FakeDriveFileIterator}
438
+ */
439
+ getFiles() {
440
+ return getFilesIterator({
441
+ parentId: this.getId(),
442
+ folderTypes: false,
443
+ fileTypes: true
444
+ })
445
+ }
446
+
447
+ /**
448
+ * get folders in this folder
449
+ * @return {FakeDriveFileIterator}
450
+ */
451
+ getFolders() {
452
+ return getFilesIterator({
453
+ parentId: this.getId(),
454
+ fileTypes: false,
455
+ folderTypes: true
456
+ })
457
+ }
458
+
459
+ /**
460
+ * get file by Id
461
+ * folders can get files
462
+ * @param {string} id
463
+ * @returns {FakeDriveFile|null}
464
+ */
465
+ getFileById(id) {
466
+ const file = getFileById({ id })
467
+ return file ? newFakeDriveFile(file) : null
468
+ }
469
+
470
+ /**
471
+ * get folder by Id
472
+ * folders can get files
473
+ * @param {string} id
474
+ * @returns {FakeDriveFolder|null}
475
+ */
476
+ getFolderById(id) {
477
+ const file = getFileById({ id })
478
+ return file ? newFakeDriveFolder(file) : null
479
+ }
480
+
481
+ /**
482
+ * get folders by name
483
+ * @param {string} name
484
+ * @return {FakeDriveFileIterator}
485
+ */
486
+ getFoldersByName(name) {
487
+ return getFilesIterator({
488
+ parentId: this.getId(),
489
+ fileTypes: false,
490
+ folderTypes: true,
491
+ qob: [`name='${name}'`]
492
+ })
493
+ }
494
+ /**
495
+ * get files by name
496
+ * @param {string} name
497
+ * @return {FakeDriveFileIterator}
498
+ */
499
+ getFilesByName(name) {
500
+ return getFilesIterator({
501
+ parentId: this.getId(),
502
+ fileTypes: true,
503
+ folderTypes: false,
504
+ qob: [`name='${name}'`]
505
+ })
506
+ }
507
+
508
+ }
509
+
510
+ /**
511
+ * basic fake DriveApp
512
+ * TODO add lots more methods
513
+ * @class FakeDriveApp
514
+ * @extends FakeDriveFolder
515
+ * @returns {FakeDriveApp}
516
+ */
517
+ export class FakeDriveApp {
518
+
519
+ constructor() {
520
+ const file = getFileById({ id: 'root', allow404: false })
521
+ this.rootFolder = newFakeDriveFolder(file)
522
+ }
523
+
524
+ /**
525
+ * get folder by Id
526
+ * folders can get files
527
+ * @returns {FakeDriveFolder}
528
+ */
529
+ getRootFolder() {
530
+ const file = getFileById({ id: 'root', allow404: false })
531
+ return newFakeDriveFolder(file)
532
+ }
533
+
534
+ getFileById(id) {
535
+ return this.rootFolder.getFileById(id)
536
+ }
537
+
538
+ getFolderById(id) {
539
+ return this.rootFolder.getFolderById(id)
540
+ }
541
+
542
+ /**
543
+ * get folders by name
544
+ * @param {string} name
545
+ * @return {FakeDriveFileIterator}
546
+ */
547
+ getFoldersByName(name) {
548
+ return getFilesIterator({
549
+ fileTypes: false,
550
+ folderTypes: true,
551
+ qob: [`name='${name}'`]
552
+ })
553
+ }
554
+
555
+ /**
556
+ * get folders by name
557
+ * @param {string} name
558
+ * @return {FakeDriveFileIterator}
559
+ */
560
+ getFilesByName(name) {
561
+ return getFilesIterator({
562
+ fileTypes: true,
563
+ folderTypes: false,
564
+ qob: [`name='${name}'`]
565
+ })
566
+ }
567
+
568
+ }
@@ -0,0 +1,141 @@
1
+ import { Utils } from '../../support/utils.js'
2
+ import { folderType } from '../../support/constants.js'
3
+ /**
4
+ * utilities for drive access shared between all fakedrive classes
5
+ */
6
+
7
+ /**
8
+ * default error handler
9
+ * @param {FakeHttpResponse} response
10
+ * @returns {Boolean}
11
+ */
12
+ export const handleError = (response) => {
13
+ if (is404(response)) {
14
+ return null
15
+ } else {
16
+ throwResponse(response)
17
+ }
18
+ }
19
+
20
+ /**
21
+ * check if a drive reponse good
22
+ * @param {SyncDriveResponse} response
23
+ * @returns {Boolean}
24
+ */
25
+ export const isGood = (response) => Math.floor(response.status / 100) === 2
26
+
27
+
28
+ /**
29
+ * check if a SyncDriveResponse is a not found
30
+ * @param {SyncDriveResponse} response
31
+ * @returns {Boolean}
32
+ */
33
+ const is404 = (response) => response.status === 404
34
+
35
+
36
+
37
+ /**
38
+ * file mimetype is a folder
39
+ * @param {File} file
40
+ * @returns {Boolean}
41
+ */
42
+ export const isFolder = (file) => file.mimeType === folderType
43
+
44
+ /**
45
+ * general throw when a reponse is bad
46
+ * @param {SyncDriveResponse} response the response from a fake fetch
47
+ */
48
+ export const throwResponse = (response) => {
49
+ throw new Error(`status: ${response.status} : ${response.statusText}`)
50
+ }
51
+
52
+ const fileFields = ["name", "parents", "id", "mimeType"]
53
+ /**
54
+ * minimum fields i'll retrieve
55
+ * @constant
56
+ * @type {object}
57
+ * @default
58
+ */
59
+ export const minFields = fileFields
60
+ export const minFieldsList = ["nextPageToken", { files: minFields }]
61
+
62
+ /**
63
+ * in preparation for merginhg field specifications
64
+ * @param {string|object []} spec the fields specs to prepare
65
+ * @param {string|object []} [model] what it should look like
66
+ * @return {Map} this normalized map can be used to remove duplicates when merging
67
+ */
68
+ const reduceFields = (spec, model) => {
69
+
70
+ const reduced = Utils.arrify(spec).reduce((p, c) => {
71
+ if (Utils.isString(c)) {
72
+ // so this would generate at top level
73
+ p.set(c, new Set())
74
+ } else {
75
+ if (!Utils.isObject(c)) {
76
+ throw new Error(`field format should be like ${JSON.stringify(model)}`)
77
+ }
78
+ //we end up with someting like Map{x:Set(null), file: Set(name,id,etc) which will allow merging of multiple of these
79
+ Reflect.ownKeys(c).forEach(k => {
80
+ if (!p.has(k)) p.set(k, new Set())
81
+ Utils.arrify(c[k] || []).forEach(f => p.get(k).add(f))
82
+ })
83
+ }
84
+ return p
85
+ }, new Map())
86
+
87
+ return reduced
88
+ }
89
+
90
+ /**
91
+ * make url params for fields
92
+ * @param {object} [extras={}] any extra fields
93
+ * @param {object} [min=minFields] the minum fields we need
94
+ * @return {string} translated to fields=... as url params
95
+ */
96
+ export const fieldUrlParams = (extras = {}, min = minFields) => {
97
+ const extrasOb = reduceFields(extras, min)
98
+ const minOb = reduceFields(min, min)
99
+
100
+ for (const [k, s] of extrasOb) {
101
+ if (!minOb.has(k)) {
102
+ minOb.set(k, new Set())
103
+ }
104
+ const mink = minOb.has(k) ? Array.from(minOb.get(k).keys()) : []
105
+ const mrg = Array.from(s.keys()).concat(mink)
106
+ minOb.set(k, new Set(mrg))
107
+ }
108
+ const fields = []
109
+ for (const [k, s] of minOb) {
110
+ if (s.size) {
111
+ fields.push(`${k}(${Array.from(s.keys()).join(",")})`)
112
+ } else {
113
+ fields.push(k)
114
+ }
115
+ }
116
+
117
+ // now make into a string
118
+ return fields.join(",")
119
+ }
120
+
121
+ /**
122
+ *
123
+ * @param {object} p the params
124
+ * @param {string} p.url the bare url
125
+ * @param {object|object []} p.min the min fields required
126
+ * @param {object|undefined|object[]} [p.fields] the fields to add to the minimum required
127
+ * @param {object} p.params and other kinds of params
128
+ * @returns {string} the decorated url
129
+ */
130
+ export const decorateUrl = ({ url, fields, params, min }) => {
131
+ // add the basic fields parameters
132
+ // annoyance:
133
+ // note that for list we need stuff like file(id,name) etc..
134
+ // but for get by id, we just want id,name
135
+ const d = decorateParams({fields, params, min})
136
+ return `${url}?${d}`
137
+ }
138
+ export const decorateParams = ({ fields, params = {}, min = minFields }) => {
139
+ const fp = fieldUrlParams(fields, min)
140
+ return Utils.makeParamOb({ fields: fp, ...params })
141
+ }