@mcpher/gas-fakes 1.0.1 → 1.0.4

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/README.RU.md ADDED
@@ -0,0 +1,315 @@
1
+ # Подтверждение концепции реализации среды Apps Script на Node
2
+
3
+ Я использую clasp/vscode для разработки приложений Google Apps Script (GAS), но при использовании нативных сервисов GAS возникает слишком много переходов туда-сюда к IDE GAS во время тестирования. Я поставил себе цель реализовать фейковую версию среды выполнения GAS на Node, чтобы хотябы сделать тесты локально.
4
+
5
+ Это всего лишь демонстрационная реализация, поэтому я реализовал очень ограниченное количество сервисов и методов, но все сложные части уже на месте, так что остается только много рутинной работы (к которой я с энтузиазмом приглашаю любых заинтересованных соавторов).
6
+
7
+ ## Начало работы
8
+
9
+ You can get the package from npm
10
+
11
+ ```sh
12
+ npm i @mcpher/gas-fakes
13
+ ```
14
+
15
+ Идея заключается в том, что вы можете запускать локально на Node сервисы GAS (пока только те, что реализованны), и он (Node) будет использовать различные API Google Workspace для эмуляции того, что произошло бы, если бы вы запустили то же самое в среде GAS.
16
+
17
+ ### Облачный проект
18
+
19
+ У вас нет доступа к облачному проекту, поддерживаемому GAS, поэтому вам нужно создать проект GCP для использования локально. Чтобы продублировать управление OAuth, обрабатываемое GAS, мы будем использовать Application Default Credentials. В этом репозитории есть некоторые скрипты для настройки и тестирования этих данных. Как только вы настроите облачный проект, перейдите в папку `shells` и добавьте свой `project id` в `setaccount.sh` и
20
+
21
+ ### Тестируйте
22
+
23
+ Рекомендую использовать тестовый проект, включенный в репозиторий, чтобы убедиться, что все настроено правильно. Он использует Fake DriveApp service для проверки Auth и т.д. Просто измените преднастройки на значения, присутствующие в вашем собственном Диске, затем `npm i && npm test`. Обратите внимание, что я использую [юнит-тестировщик](https://ramblings.mcpher.com/apps-script-test-runner-library-ported-to-node/), который работает как в GAS, так и в Node, поэтому те же самые тесты будут выполняться в обоих средах.
24
+
25
+ ### Передача в GAS
26
+
27
+ Скрипт `togas.sh` переместит ваши файлы в gas - просто установите папки `SOURCE` и `TARGET` в скрипте. Убедитесь, что у вас есть манифест `appsscript.json` в папке `SOURCE`, поскольку **gas-fakes** читает его для обработки OAuth на Node.
28
+
29
+ Вы можете написать проект, который будет работать на Node и вызывать сервисы GAS, и он также будет работать в среде GAS без изменений кода, за исключением того, что на стороне Node у вас есть этот один импорт
30
+
31
+ ```js
32
+ // all the fake services are here
33
+ import '@mcpher/gas-fakes/main.js'
34
+ ```
35
+
36
+ `togas.sh` удалит `imports` и `exports` по пути к Apps Script, который их не поддерживает.
37
+
38
+ ## Подход
39
+
40
+ Google не опубликовали детали о среде исполнения GAS (насколько мне известно). Мы знаем то, что они раньше работали на эмуляторе JavaScript под названием [Rhino](https://ramblings.mcpher.com/gassnippets2/what-javascript-engine-is-apps-script-running-on/), основанном на Java, но несколько лет назад перешели на среду исполнения V8. Помимо этого, мы не знаем почти ничего, кроме того, что они работают где-то на серверах Google.
41
+
42
+ Было 3 основные сложные проблемы, которые нужно было преодолеть, чтобы это заработало
43
+
44
+ - GAS полностью синхронный, тогда как замена вызовов API Workspace на Node асинхронная.
45
+ - GAS автоматически обрабатывает инициализацию OAuth из файла манифеста, тогда как нам нужна дополнительный код или альтернативные подходы на Node.
46
+ - Сервисные синглтоны (например, DriveApp) автоматически инициализируются и доступны в глобальном пространстве имен, тогда как в Node им нужна некоторая пост-AUTH инициализация, упорядочивание инициализации и экспозиция.
47
+ - Итераторы GAS не такие же, как стандартные итераторы, так как у них есть метод `hasNext()` и они не ведут себя одинаково.
48
+
49
+ Помимо этого, реализация - это просто много рутинной работы. Вот как я справился с этими 3 проблемами.
50
+
51
+ ### Синхронность против Асинхронности
52
+
53
+ Хотя Apps Script поддерживает синтаксис async/await/promise, он работает в блокирующем режиме. Я действительно не хотел настаивать на асинхронном кодировании в коде, ориентированном на GAS, поэтому мне нужно было найти способ эмулировать то, что, вероятно, делает среда GAS.
54
+
55
+ Так как асинхронность является фундаментальной для Node, нет простого способа преобразовать асинхронность в синхронность. Однако существует такое понятие как *[child-process](https://nodejs.org/api/child_process.html#child-process)*, который вы можете запустить для выполнения вещей, и он имеет метод [`execSync`](https://nodejs.org/api/child_process.html#child_processexecsynccommand-options), который задерживает возврат из дочернего процесса до тех пор, пока очередь переданного обещания не будет полностью завершена. Таким образом, самым простым решением является запуск асинхронного метода в дочернем процессе, ожидание его завершения и возврат результатов синхронно. Я обнаружил, что [Sindre Sorhus](https://github.com/sindresorhus) использует этот подход с [make-synchronous](https://github.com/sindresorhus/make-synchronous), поэтому и я использую это.
56
+
57
+ Вот простой пример того, как получить информацию о токене доступа синхронно
58
+
59
+ ```js
60
+ /**
61
+ * a sync version of token checking
62
+ * @param {string} token the token to check
63
+ * @returns {object} access token info
64
+ */
65
+ const fxCheckToken = (accessToken) => {
66
+
67
+ // now turn all that into a synchronous function - it runs as a subprocess, so we need to start from scratch
68
+ const fx = makeSynchronous(async accessToken => {
69
+ const { default: got } = await import('got')
70
+ const tokenInfo = await got(`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`).json()
71
+ return tokenInfo
72
+ })
73
+
74
+ const result = fx(accessToken)
75
+ return result
76
+ }
77
+ ```
78
+
79
+ ### OAuth
80
+
81
+ Здесь мы имеем два компонента решения.
82
+
83
+ #### Приложение с параметрами доступа по умолчанию / Application default credentials (ADC)
84
+
85
+ Чтобы избежать большого количества специфичного для Node кода и учетных данных, но все же обрабатывать OAuth, я решил, что мы можем просто полагаться на ADC. Это проблема, о которой я уже писал здесь [Application Default Credentials with Google Cloud and Workspace APIs](https://ramblings.mcpher.com/application-default-credentials-with-google-cloud-and-workspace-apis/)
86
+
87
+ Для настройки этого установите ID вашего проекта GCP и дополнительные области, которые вам нужны, в `shells/setaccount.sh`. В этом примере я сохраняю обычные области ADC и добавляю дополнительную область для доступа к Drive
88
+
89
+ ```sh
90
+ # project ID
91
+ P=YOUR_GCP_PROJECT_ID
92
+
93
+ # config to activate - multiple configs can each be named
94
+ # here we're working on the default project configuration
95
+ AC=default
96
+
97
+ # these are the ones it sets by default - take some of these out if you want to minimize access
98
+ DEFAULT_SCOPES="https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/drive,openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/sqlservice.login"
99
+
100
+ # these are the ones we want to add (note comma at beginning)
101
+ EXTRA_SCOPES=",https://www.googleapis.com/auth/drive"
102
+
103
+ .....etc
104
+ ```
105
+
106
+ После настройки самого проекта вы можете выполнить скрипт, и это настроит ваш ADC, чтобы запускать любые службы, которым требуются области, которые вы добавили.
107
+
108
+ ##### примечание
109
+
110
+ Хотя может быть искушение добавить `https://www.googleapis.com/auth/script.external_request`, это не обязательно для ADC и, на самом деле, вызовет ошибку. Конечно, вам понадобится это в вашем манифесте Apps script.
111
+
112
+ ##### тестирование ADC
113
+
114
+ `shells/testtoken.sh` можно использовать для проверки, что вы можете сгенерировать токен с достаточной областью. В этом примере я проверяю, что могу получить доступ к файлу, которым владею. Измените `id` на один из ваших.
115
+
116
+ ```sh
117
+ # check tokens have scopes required for DRIVE access
118
+ # set below to a fileid on drive you have access to
119
+ FILE_ID=SOME_FILE_ID
120
+
121
+ ....etc
122
+ ```
123
+
124
+ Рекомендую сделать это, чтобы убедиться, что Auth работает нормально, прежде чем начинать кодировать свое приложение.
125
+
126
+ #### Файл манифеста
127
+
128
+ **gas-fakes** читает файл манифеста, чтобы узнать, какие области вам нужны в проекте, использует библиотеку Google Auth для попытки авторизации и имеет `ScriptApp.getOauthToken()`, чтобы возвращать достаточно специфицированный токен, как это делает среда GAS. Просто убедитесь, что у вас есть `appsscript.json` в той же папке, что и ваш основной скрипт.
129
+
130
+ ### Глобальная инициализация
131
+
132
+ Это было немного проблематично для реализации последовательности инициализации, но я хотел убедиться, что любые GAS сервисы, которые имитируются, доступны и инициализированы на стороне Node, как и в GAS. На момент написания (подмножество методов) этих сервисов реализовано.
133
+
134
+ v1.0.0 подтверждение концепции для
135
+
136
+ - `DriveApp`
137
+ - `ScriptApp`
138
+ - `UrlFetchApp`
139
+ - `Utilities`
140
+
141
+ #### Прокси и globalThis
142
+
143
+ Каждый сервис имеет `FakeClass`, но мне нужно было, чтобы цикл Auth был инициирован и выполнен перед тем, как сделать их публичными. Использование прокси был самым простым подходом.
144
+
145
+ Вот код для `ScriptApp`
146
+
147
+ ```js
148
+ /**
149
+ * adds to global space to mimic Apps Script behavior
150
+ */
151
+ const name = "ScriptApp"
152
+
153
+ if (typeof globalThis[name] === typeof undefined) {
154
+
155
+ console.log ('setting script app to global')
156
+
157
+ const getApp = () => {
158
+
159
+ // if it hasn't been intialized yet then do that
160
+ if (!_app) {
161
+
162
+ // we also need to do the manifest scopes thing and the project id
163
+ const projectId = Syncit.fxGetProjectId()
164
+ const manifest = Syncit.fxGetManifest()
165
+ Auth.setProjectId (projectId)
166
+ Auth.setManifestScopes(manifest)
167
+
168
+ _app = {
169
+ getOAuthToken,
170
+ requireAllScopes,
171
+ requireScopes,
172
+ AuthMode: {
173
+ FULL: 'FULL'
174
+ }
175
+ }
176
+
177
+
178
+ }
179
+ // this is the actual driveApp we'll return from the proxy
180
+ return _app
181
+ }
182
+
183
+
184
+ Proxies.registerProxy(name, getApp)
185
+
186
+ }
187
+ ```
188
+
189
+ Вот как регистрируются прокси
190
+
191
+ ```js
192
+
193
+ /**
194
+ * diverts the property get to another object returned by the getApp function
195
+ * @param {function} a function to get the proxy object to substitutes
196
+ * @returns {function} a handler for a proxy
197
+ */
198
+ const getAppHandler = (getApp) => {
199
+ return {
200
+
201
+ get(_, prop, receiver) {
202
+ // this will let the caller know we're not really running in Apps Script
203
+ return (prop === 'isFake') ? true : Reflect.get(getApp(), prop, receiver);
204
+ },
205
+
206
+ ownKeys(_) {
207
+ return Reflect.ownKeys(getApp())
208
+ }
209
+ }
210
+ }
211
+
212
+ const registerProxy = (name, getApp) => {
213
+ const value = new Proxy({}, getAppHandler(getApp))
214
+ // add it to the global space to mimic what apps script does
215
+ Object.defineProperty(globalThis, name, {
216
+ value,
217
+ enumerable: true,
218
+ configurable: false,
219
+ writable: false,
220
+ });
221
+ }
222
+ ```
223
+
224
+ Коротко говоря, сервис регистрируется как пустой объект, но при любой попытке получить доступ к нему фактически возвращает другой объект, который обрабатывает запрос. В примере `ScriptApp` является пустым объектом, но доступ к `ScriptApp.getOAuthToken()` возвращает объект ложный (Fake) `ScriptApp`, который был инициализирован.
225
+
226
+ Также есть тест, чтобы проверить, запущены ли вы в GAS или на Node - `ScriptApp.isFake`
227
+
228
+ ### Итераторы
229
+
230
+ Итератор, созданный генератором, не имеет функции `hasNext()`, в то время как итераторы GAS имеют. Чтобы обойти это, мы можем создать обычный итератор Node, но ввести обертку, чтобы конструктор фактически получил первый элемент, а `next()` использовал значение, которое мы уже посмотрели. Вот обертка для преобразования итератора в стиль GAS
231
+
232
+ ```js
233
+ import { Proxies } from './proxies.js'
234
+ /**
235
+ * this is a class to add a hasnext to a generator
236
+ * @class Peeker
237
+ *
238
+ */
239
+ class Peeker {
240
+ /**
241
+ * @constructor
242
+ * @param {function} generator the generator function to add a hasNext() to
243
+ * @returns {Peeker}
244
+ */
245
+ constructor(generator) {
246
+ this.generator = generator
247
+ // in order to be able to do a hasnext we have to actually get the value
248
+ // this is the next value stored
249
+ this.peeked = generator.next()
250
+ }
251
+
252
+ /**
253
+ * we see if there's a next if the peeked at is all over
254
+ * @returns {Boolean}
255
+ */
256
+ hasNext () {
257
+ return !this.peeked.done
258
+ }
259
+
260
+ /**
261
+ * get the next value - actually its already got and storef in peeked
262
+ * @returns {object} {value, done}
263
+ */
264
+ next () {
265
+ if (!this.hasNext()) {
266
+ // TODO find out what driveapp does
267
+ throw new Error ('iterator is exhausted - there is no more')
268
+ }
269
+ // instead of returning the next, we return the prepeeked next
270
+ const value = this.peeked.value
271
+ this.peeked = this.generator.next()
272
+ return value
273
+ }
274
+ }
275
+
276
+ export const newPeeker = (...args) => Proxies.guard(new Peeker (...args))
277
+ ```
278
+
279
+ И пример использования, создающий итератор родителей из файла API Drive
280
+
281
+ ```js
282
+ /**
283
+ * this gets an intertor to fetch all the parents meta data
284
+ * @param {FakeDriveMeta} {file} the meta data
285
+ * @returns {object} {Peeker}
286
+ */
287
+ const getParentsIterator = ({
288
+ file
289
+ }) => {
290
+
291
+ Utils.assert.object(file)
292
+ Utils.assert.array(file.parents)
293
+
294
+ function* filesink() {
295
+ // the result tank, we just get them all by id
296
+ let tank = file.parents.map(id => getFileById({ id, allow404: false }))
297
+
298
+ while (tank.length) {
299
+ yield newFakeDriveFolder(tank.splice(0, 1)[0])
300
+ }
301
+ }
302
+
303
+ // create the iterator
304
+ const parentsIt = filesink()
305
+
306
+ // a regular iterator doesnt support the same methods
307
+ // as Apps Script so we'll fake that too
308
+ return newPeeker(parentsIt)
309
+
310
+ }
311
+ ```
312
+
313
+ ## Помощь
314
+
315
+ Как я уже упоминал ранее, чтобы развивать это дальше, мне понадобится много помощи для расширения поддерживаемых методов и сервисов - поэтому, если вы считаете, что это будет полезно для вас, и хотите сотрудничать, пожалуйста, свяжитесь со мной по [bruce@mcpher.com](mailto:bruce@mcpher.com) и мы поговорим.