@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,133 @@
1
+ // fake script app to get oauth token from application default credentials on Apps Script
2
+ // first set up and test ADC with required scopes - see https://ramblings.mcpher.com/application-default-credentials-with-google-cloud-and-workspace-apis
3
+ // Note that all async type functions have been converted to synch to make it Apps Script like
4
+
5
+ import { Syncit } from '../../support/syncit.js'
6
+ import { Auth } from '../../support/auth.js'
7
+ import { Proxies } from '../../support/proxies.js'
8
+
9
+ /**
10
+ * fake ScriptApp.getOAuthToken
11
+ * @return {string} token
12
+ */
13
+ const getOAuthToken = () => {
14
+ // make a sync request in a subprocess to get an access token
15
+ return Syncit.fxGetAccessToken()
16
+ }
17
+
18
+ const limitMode = (mode) => {
19
+ if (mode !== ScriptApp.AuthMode.FULL) {
20
+ throw new Error(`only ${ScriptApp.AuthMode.FULL} is supported as mode for now`)
21
+ }
22
+ // the scopes from the manifest should have been set
23
+ if (!Auth.hasAuth()) {
24
+ throw new Error(`manifest hasnt been initialized`)
25
+ }
26
+ return mode
27
+ }
28
+
29
+ /**
30
+ * these have been converted with a sync version
31
+ * @param {ScriptApp.AuthMode} mode mode to check
32
+ * @returns null
33
+ */
34
+ const requireAllScopes = (mode) => {
35
+ limitMode(mode)
36
+ return checkScopesMatch(Array.from(Auth.getAuthedScopes().keys()))
37
+ }
38
+
39
+ /**
40
+ * these have been converted with a sync version
41
+ * see https://developers.google.com/apps-script/reference/script/script-app#requireScopes(AuthMode,String)
42
+ * @param {ScriptApp.AuthMode} mode mode to check
43
+ * @param {string[]} required scopes required
44
+ * @returns null
45
+ */
46
+ const requireScopes = (mode, required) => {
47
+ // only supporting FULL for now
48
+ limitMode(mode)
49
+ return checkScopesMatch(required)
50
+ }
51
+
52
+ /**
53
+ * a sync version of token checking
54
+ * @param {string} token the token to check
55
+ * @returns {object} access token info
56
+ */
57
+ const checkToken = (accessToken) => {
58
+ return Syncit.fxCheckToken(accessToken)
59
+ }
60
+
61
+ /**
62
+ * check that all scopes requested have been asked for
63
+ * @param {string[]} required
64
+ * @returns null
65
+ */
66
+ const checkScopesMatch = (required) => {
67
+
68
+ // we can do a sync version of the accesstoken fetch
69
+ const token = getOAuthToken()
70
+ const tokenInfo = checkToken(token)
71
+
72
+ // now we're syncronous all the way
73
+ const tokened = new Set(tokenInfo.scope.split(" "))
74
+
75
+ // see which ones are missing
76
+ const missing = required.filter(s => {
77
+ // setting this scope causes gcloud to block - but we dot need it anywat as the default ADC allow it, so we have to skip it
78
+ const ignore = "https://www.googleapis.com/auth/script.external_request"
79
+ // if drive is authorized and drive.readonly is required that's okay too
80
+ // if drive.readonly is authorized and drive is requested thats not
81
+ return !(s === ignore || tokened.has(s.replace(/\.readonly$/, "")))
82
+ })
83
+
84
+ if (missing.length) {
85
+ throw new Error(`These scopes are required but have not been authorized ${missing.join(",")}`)
86
+ }
87
+ return null
88
+
89
+ }
90
+
91
+ // This will eventually hold a proxy for ScriptApp
92
+ let _app = null
93
+
94
+
95
+ /**
96
+ * adds to global space to mimic Apps Script behavior
97
+ */
98
+ const name = "ScriptApp"
99
+
100
+ if (typeof globalThis[name] === typeof undefined) {
101
+
102
+ console.log ('setting script app to global')
103
+
104
+ const getApp = () => {
105
+
106
+ // if it hasn't been intialized yet then do that
107
+ if (!_app) {
108
+
109
+ // we also need to do the manifest scopes thing and the project id
110
+ const projectId = Syncit.fxGetProjectId()
111
+ const manifest = Syncit.fxGetManifest()
112
+ Auth.setProjectId (projectId)
113
+ Auth.setManifestScopes(manifest)
114
+
115
+ _app = {
116
+ getOAuthToken,
117
+ requireAllScopes,
118
+ requireScopes,
119
+ AuthMode: {
120
+ FULL: 'FULL'
121
+ }
122
+ }
123
+
124
+
125
+ }
126
+ // this is the actual driveApp we'll return from the proxy
127
+ return _app
128
+ }
129
+
130
+
131
+ Proxies.registerProxy(name, getApp)
132
+
133
+ }
@@ -0,0 +1,128 @@
1
+ // fake Apps Script UrlFetchApp
2
+
3
+ import { Auth } from '../../support/auth.js'
4
+ import { Syncit } from '../../support/syncit.js'
5
+ import { Proxies } from '../../support/proxies.js'
6
+ // Note that all async type functions have been converted to synch ro make it Apps Script like
7
+
8
+
9
+ /**
10
+ * make got response look like UrlFetchApp response
11
+ * @param {Response} reponse
12
+ * @return {FakeHTTPResponse} UrlFetchApp flavor
13
+ */
14
+ const responsify = (response) => {
15
+
16
+ // TODO test all these
17
+ // getAllHeaders() Object Returns an attribute/value map of headers for the HTTP response, with headers that have multiple values returned as arrays.
18
+ // need to identify the difference between this and getHeaders
19
+ const getAllHeaders = () => response.rawHeaders
20
+
21
+ // getResponseCode() Integer Get the HTTP status code (200 for OK, etc.) of an HTTP response
22
+ const getResponseCode = () => response.statusCode
23
+
24
+ // getContentText() String Gets the content of an HTTP response encoded as a string.
25
+
26
+ const getContentText = () => response.body
27
+
28
+ // getHeaders() Object Returns an attribute/value map of headers for the HTTP response.
29
+ const getHeaders = () => response.headers
30
+
31
+ /* TODO
32
+ getAs(contentType) Blob Return the data inside this object as a blob converted to the specified content type.
33
+ getBlob() Blob Return the data inside this object as a blob.
34
+ getContent() Byte[] Gets the raw binary content of an HTTP response.
35
+ getContentText(charset) String Returns the content of an HTTP response encoded as a string of the given charset.
36
+ */
37
+ return {
38
+ getAllHeaders,
39
+ getResponseCode,
40
+ getContentText,
41
+ getHeaders
42
+ }
43
+ }
44
+
45
+ // this has been syncified
46
+ const fetch = (url, options = {}) => {
47
+
48
+ // check options for method and provide default
49
+ options.method = options.method || "get"
50
+ options = Auth.googify(options)
51
+
52
+ const responseFields = [
53
+ 'rawHeaders',
54
+ 'statusCode',
55
+ 'body',
56
+ 'headers'
57
+ ]
58
+
59
+ const response = Syncit.fxFetch(url, options, responseFields)
60
+ return responsify(response)
61
+ }
62
+
63
+
64
+ // This will eventually hold a proxy for DriveApp
65
+ let _app = null
66
+
67
+ /**
68
+ * adds to global space to mimic Apps Script behavior
69
+ */
70
+ const name = "UrlFetchApp"
71
+ if (typeof globalThis[name] === typeof undefined) {
72
+
73
+ const getApp = () => {
74
+ // if it hasne been intialized yet then do that
75
+ if (!_app) {
76
+ _app = {
77
+ fetch
78
+ }
79
+ }
80
+ // this is the actual driveApp we'll return from the proxy
81
+ return _app
82
+ }
83
+
84
+ Proxies.registerProxy (name, getApp)
85
+
86
+ }
87
+
88
+
89
+ /** got reponse props
90
+ [ '_events', 'object' ],
91
+ [ '_readableState', 'object' ],
92
+ [ '_writableState', 'object' ],
93
+ [ 'allowHalfOpen', 'boolean' ],
94
+ [ '_destroy', 'function' ],
95
+ [ '_maxListeners', 'undefined' ],
96
+ [ '_eventsCount', 'number' ],
97
+ [ 'socket', 'object' ],
98
+ [ 'httpVersionMajor', 'number' ],
99
+ [ 'httpVersionMinor', 'number' ],
100
+ [ 'httpVersion', 'string' ],
101
+ [ 'complete', 'boolean' ],
102
+ [ 'rawHeaders', 'object' ],
103
+ [ 'rawTrailers', 'object' ],
104
+ [ 'joinDuplicateHeaders', 'undefined' ],
105
+ [ 'aborted', 'boolean' ],
106
+ [ 'upgrade', 'boolean' ],
107
+ [ 'url', 'string' ],
108
+ [ 'method', 'object' ],
109
+ [ 'statusCode', 'number' ],
110
+ [ 'statusMessage', 'string' ],
111
+ [ 'client', 'object' ],
112
+ [ '_consuming', 'boolean' ],
113
+ [ '_dumped', 'boolean' ],
114
+ [ 'req', 'object' ],
115
+ [ 'timings', 'object' ],
116
+ [ 'headers', 'object' ],
117
+ [ 'setTimeout', 'function' ],
118
+ [ 'trailers', 'object' ],
119
+ [ 'requestUrl', 'object' ],
120
+ [ 'redirectUrls', 'object' ],
121
+ [ 'request', 'object' ],
122
+ [ 'isFromCache', 'boolean' ],
123
+ [ 'ip', 'string' ],
124
+ [ 'retryCount', 'number' ],
125
+ [ 'ok', 'boolean' ],
126
+ [ 'rawBody', 'object' ],
127
+ [ 'body', 'string' ]
128
+ */
@@ -0,0 +1,37 @@
1
+ import sleepSynchronously from 'sleep-synchronously';
2
+ import { Utils } from '../../support/utils.js'
3
+ import { Proxies } from '../../support/proxies.js'
4
+ import { newBlob } from './fakeblob.js'
5
+ /**
6
+ * a blocking sleep to emulate Apps Script
7
+ * @param {number} ms number of milliseconds to sleep
8
+ */
9
+ const sleep = (ms) => {
10
+ sleepSynchronously(Utils.assertType (ms, 'number',`Cannot convert ${ms} to int.`));
11
+ }
12
+
13
+
14
+ // This will eventually hold a proxy for DriveApp
15
+ let _app = null
16
+
17
+ /**
18
+ * adds to global space to mimic Apps Script behavior
19
+ */
20
+ const name = "Utilities"
21
+ if (typeof globalThis[name] === typeof undefined) {
22
+
23
+ const getApp = () => {
24
+ // if it hasne been intialized yet then do that
25
+ if (!_app) {
26
+ _app = {
27
+ sleep,
28
+ newBlob
29
+ }
30
+ }
31
+ // this is the actual driveApp we'll return from the proxy
32
+ return _app
33
+ }
34
+
35
+ Proxies.registerProxy (name, getApp)
36
+
37
+ }
@@ -0,0 +1,73 @@
1
+ import { Proxies } from '../../support/proxies.js'
2
+ import { Utils } from '../../support/utils.js'
3
+ import { isGoogleType } from '../../support/constants.js'
4
+
5
+ import mime from 'mime';
6
+ // Apps Script blob fake
7
+
8
+
9
+ class FakeBlob {
10
+ /**
11
+ *
12
+ * @constructor
13
+ * @param {byte[]} [data] data
14
+ * @param {string} [contentType]
15
+ * @param {string} [name]
16
+ * @returns {FakeDriveFile}
17
+ */
18
+ constructor(data, contentType, name) {
19
+ this._data = Utils.settleAsBytes(data)
20
+ this._contentType = contentType ||
21
+ (Utils.isString(data) ? 'text/plain' : null)
22
+ this._name = name || null
23
+ }
24
+
25
+
26
+ getBytes() {
27
+ return this._data
28
+ }
29
+
30
+ getContentType() {
31
+ return this._contentType
32
+ }
33
+
34
+ getName() {
35
+ return this._name
36
+ }
37
+
38
+ isGoogleType() {
39
+ return isGoogleType(this.getContentType())
40
+ }
41
+
42
+ getDataAsString(charset) {
43
+ return Utils.bytesToString(this._data, charset)
44
+ }
45
+
46
+ copyBlob() {
47
+ return newBlob(this.getBytes(), this.getContentType(), this.getName())
48
+ }
49
+
50
+ setBytes(data) {
51
+ this._data = Utils.assertType(data, 'array')
52
+ return this
53
+ }
54
+ setContentType(contentType) {
55
+ this._contentType = contentType
56
+ return this
57
+ }
58
+
59
+ setContentTypeFromExtension() {
60
+ return this.setContentType(mime.getType(this.getName()))
61
+ }
62
+
63
+ setDataFromString(string, charset) {
64
+ return this.setBytes(Utils.stringToBytes(string, charset))
65
+ }
66
+
67
+ setName(name) {
68
+ this._name = name
69
+ return this
70
+ }
71
+
72
+ }
73
+ export const newBlob = (...args) => Proxies.guard(new FakeBlob(...args))
@@ -0,0 +1,154 @@
1
+ import { GoogleAuth } from 'google-auth-library'
2
+ import {readFile} from 'node:fs/promises'
3
+ import got from 'got'
4
+ import path from 'path'
5
+ import {Utils} from './utils.js'
6
+
7
+ const _authScopes = new Set([])
8
+ let _auth = null
9
+ let _projectId = null
10
+
11
+ const setProjectId = (projectId) => _projectId = projectId
12
+ const setManifestScopes = (manifest) => setAuth(Utils.arrify(manifest.oauthScopes))
13
+
14
+ /**
15
+ * get the manifest scopes and set them
16
+ * @param {string} [manifestParh] the manifest file path
17
+ * @returns {Promise <string[]>} the scopes
18
+ */
19
+ const initManifestScopes = async (manifestParh) => {
20
+ const scopes = await getManifestScopes(manifestParh)
21
+ setAuth (scopes)
22
+ // we also set the project id to avoid catching that every time
23
+ // the project id is required for fetches to workspace apis using application default credentials
24
+ _projectId = await authProjectId()
25
+ return scopes
26
+ }
27
+ /**
28
+ * get the manifest content and parse
29
+ * @param {string} [manifestPath] the manifest file path
30
+ * @returns {Promise <object>} the manifest
31
+ */
32
+ const getManifest = async (manifestPath='./appsscript.json') => {
33
+ const mainDir = path.dirname(process.argv[1])
34
+ const manifestFile = path.resolve ( mainDir, manifestPath)
35
+ console.log (`using manifest file:${manifestFile}`)
36
+ const contents = await readFile(manifestFile, { encoding: 'utf8' })
37
+ return JSON.parse (contents)
38
+ }
39
+
40
+ /**
41
+ * get the scopes from the manifest
42
+ * @param {string} [path] the manifest file path
43
+ * @returns {Promise <string[]>} the scopes required by the manifest
44
+ */
45
+ const getManifestScopes = async (path) => {
46
+ const manifest = await getManifest(path)
47
+ return Utils.arrify(manifest.oauthScopes)
48
+ }
49
+ /**
50
+ * we'll be using adc credentials so no need for any special auth here
51
+ * the idea here is to keep addign scopes to any auth so we have them all
52
+ * @param {string[]} [scopes=[]] the required scopes will be added to existing scopes already asked for
53
+ * @returns {GoogleAuth.auth}
54
+ */
55
+ const setAuth = (scopes = []) => {
56
+
57
+ if (!hasAuth() || !scopes.every(s => _authScopes.has(s))) {
58
+ _auth = new GoogleAuth({
59
+ scopes
60
+ })
61
+ scopes.forEach(s => _authScopes.add(s))
62
+ }
63
+ return getAuth()
64
+ }
65
+
66
+
67
+ /**
68
+ * if we're doing a fetch on drive API we need a special header
69
+ */
70
+ const googify = (options = {}) => {
71
+ const { headers } = options
72
+
73
+ // no auth, therefore no need
74
+ if (!headers || !hasAuth()) return options
75
+
76
+ // if no authorization, we dont need this either
77
+ if (!Reflect.has(headers, "Authorization")) return options
78
+
79
+ // we'll need the projectID for this
80
+ // note - you must add the x-goog-user-project header, otherwise it'll use some nonexistent project
81
+ // see https://cloud.google.com/docs/authentication/rest#set-billing-project
82
+ // this has been syncified
83
+ const projectId = getProjectId()
84
+ return {
85
+ ...options,
86
+ headers: {
87
+ "x-goog-user-project": projectId,
88
+ ...headers
89
+ }
90
+ }
91
+
92
+ }
93
+ /**
94
+ * @returns {Promise <string>} the projectId
95
+ */
96
+ const authProjectId = async () => {
97
+ return getAuth().getProjectId()
98
+ }
99
+
100
+ /**
101
+ * this would have been set up when manifest was imported
102
+ * @returns {string} the project id
103
+ */
104
+ const getProjectId = () => {
105
+ if (Utils.isNU(_projectId)) {
106
+ throw new Error ('Project id not set - did you forget to run initManifestScopes?')
107
+ }
108
+ return _projectId
109
+ }
110
+
111
+ /**
112
+ * @returns {Boolean} checks to see if auth has bee initialized yet
113
+ */
114
+ const hasAuth = () => Boolean (_auth)
115
+
116
+ /**
117
+ * @returns {GoogleAuth.auth}
118
+ */
119
+ const getAuth = () => {
120
+ if (!hasAuth()) throw new Error(`auth hasnt been intialized with setAuth yet`)
121
+ return _auth
122
+ }
123
+
124
+ /**
125
+ * gets the info about an access token
126
+ * @param {string} accessToken the accessToken to check
127
+ * @returns {Promise <object>} access toekn info
128
+ */
129
+
130
+ const checkToken = async (accessToken) => {
131
+ const pack = await got(`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`).json()
132
+ return pack
133
+ }
134
+
135
+ /**
136
+ * these are the ones that have been so far requested
137
+ * @returns {Set}
138
+ */
139
+ const getAuthedScopes = () => _authScopes
140
+
141
+ export const Auth = {
142
+ checkToken,
143
+ getAuth,
144
+ hasAuth,
145
+ getProjectId,
146
+ setAuth,
147
+ getManifestScopes,
148
+ getManifest,
149
+ initManifestScopes,
150
+ getAuthedScopes,
151
+ googify,
152
+ setProjectId,
153
+ setManifestScopes
154
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @constant
3
+ * @type {string}
4
+ * @default
5
+ */
6
+ export const gooType = "application/vnd.google-apps"
7
+ /**
8
+ * mimetype of a folder
9
+ * @constant
10
+ * @type {string}
11
+ * @default
12
+ */
13
+ export const folderType = `${gooType}.folder`
14
+
15
+ export const isGoogleType = (mimeType) =>
16
+ mimeType && mimeType.substring(0,gooType.length) === gooType
@@ -0,0 +1,44 @@
1
+ import { Proxies } from './proxies.js'
2
+ /**
3
+ * this is a class to add a hasnext to a generator
4
+ * @class Peeker
5
+ *
6
+ */
7
+ class Peeker {
8
+ /**
9
+ * @constructor
10
+ * @param {function} generator the generator function to add a hasNext() to
11
+ * @returns {Peeker}
12
+ */
13
+ constructor(generator) {
14
+ this.generator = generator
15
+ // in order to be able to do a hasnext we have to actually get the value
16
+ // this is the next value stored
17
+ this.peeked = generator.next()
18
+ }
19
+
20
+ /**
21
+ * we see if there's a next if the peeked at is all over
22
+ * @returns {Boolean}
23
+ */
24
+ hasNext () {
25
+ return !this.peeked.done
26
+ }
27
+
28
+ /**
29
+ * get the next value - actually its already got and storef in peeked
30
+ * @returns {object} {value, done}
31
+ */
32
+ next () {
33
+ if (!this.hasNext()) {
34
+ // TODO find out what driveapp does
35
+ throw new Error ('iterator is exhausted - there is no more')
36
+ }
37
+ // instead of returning the next, we return the prepeeked next
38
+ const value = this.peeked.value
39
+ this.peeked = this.generator.next()
40
+ return value
41
+ }
42
+ }
43
+
44
+ export const newPeeker = (...args) => Proxies.guard(new Peeker (...args))
@@ -0,0 +1,74 @@
1
+
2
+
3
+ /**
4
+ * diverts the property get to another object returned by the getApp function
5
+ * @param {function} a function to get the proxy object to substitutes
6
+ * @returns {function} a handler for a proxy
7
+ */
8
+ const getAppHandler = (getApp) => {
9
+ return {
10
+
11
+ get(_, prop, receiver) {
12
+ // this will let the caller know we're not really running in Apps Script
13
+ return (prop === 'isFake') ? true : Reflect.get(getApp(), prop, receiver);
14
+ },
15
+
16
+ ownKeys(_) {
17
+ return Reflect.ownKeys(getApp())
18
+ }
19
+ }
20
+ }
21
+
22
+ const registerProxy = (name, getApp) => {
23
+ const value = new Proxy({}, getAppHandler(getApp))
24
+ // add it to the global space to mimic what apps script does
25
+ Object.defineProperty(globalThis, name, {
26
+ value,
27
+ enumerable: true,
28
+ configurable: false,
29
+ writable: false,
30
+ });
31
+ }
32
+
33
+
34
+
35
+
36
+ /**
37
+ * for validating attempts to access non existent properties
38
+ */
39
+ const validateProperties = () => {
40
+ return {
41
+ get(target, prop, receiver) {
42
+ if (
43
+ // skip any inserted symbos
44
+ typeof prop !== 'symbol' &&
45
+ // sometimes typeof & console.log looks for ths
46
+ prop !== 'inspect' &&
47
+ // this is a mysterious property that APPS script sometimes checks for
48
+ prop !== '__GS_INTERNAL_isProxy' &&
49
+ // check the object has this property
50
+ !Reflect.has(target, prop)
51
+ )
52
+ throw new Error(`attempt to get non-existent property ${prop}: may not be implemented yet`)
53
+
54
+ return Reflect.get(target, prop, receiver);
55
+ },
56
+
57
+ set(target, prop, value, receiver) {
58
+ if (!Reflect.has(target, prop))
59
+ throw `guard attempt to set non-existent property ${prop}`;
60
+ return Reflect.set(target, prop, value, receiver);
61
+ },
62
+ };
63
+ }
64
+
65
+ // used to trap access to unknown properties
66
+ const guard = (target) => {
67
+ return new Proxy(target, validateProperties);
68
+ }
69
+
70
+ export const Proxies = {
71
+ getAppHandler,
72
+ registerProxy,
73
+ guard
74
+ }