@sasjs/cli 6.30.0 → 6.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/build/internal/mockSite/assets/api-console.css +365 -0
- package/build/commands/build/internal/mockSite/assets/api-console.js +762 -0
- package/build/commands/build/internal/mockSite/assets/api-index.html +83 -0
- package/build/commands/build/internal/mockSite/assets/boot.js +23 -1
- package/build/commands/build/internal/mockSite/assets/persist.js +76 -5
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +413 -50
- package/build/commands/build/internal/mockSite/assets/sw.js +156 -15
- package/build/commands/build/internal/mockSite/mockSite.d.ts +25 -0
- package/build/commands/build/internal/mockSite/mockSite.js +311 -24
- package/build/commands/build/internal/mockSite/spec/apiConsole.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/apiConsole.spec.js +1371 -0
- package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.d.ts +1 -0
- package/build/commands/build/internal/mockSite/spec/apiConsoleBasePath.spec.js +303 -0
- package/build/commands/build/internal/mockSite/spec/boot.spec.js +50 -3
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +217 -3
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +179 -1
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +546 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +407 -53
- package/build/commands/version/spec/version.spec.js +11 -1
- package/build/commands/version/version.d.ts +5 -3
- package/build/commands/version/version.js +17 -7
- package/build/types/mockWebBasePath.d.ts +9 -0
- package/package.json +13 -32
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API console for a browser-hosted build.
|
|
3
|
+
*
|
|
4
|
+
* A plain page with no framework and no build step: it reads the route table the
|
|
5
|
+
* build wrote into `operations.json`, renders a form per route from the
|
|
6
|
+
* parameters the API declares, and sends the request the way an application
|
|
7
|
+
* would - same origin, same service worker, same answers. Nothing here is a
|
|
8
|
+
* second implementation of the API; a route that fails here fails for the app
|
|
9
|
+
* too, which is the point of having it on a demo site.
|
|
10
|
+
*
|
|
11
|
+
* Three deliberate choices:
|
|
12
|
+
*
|
|
13
|
+
* - no innerHTML with anything that came from the API. A response body is data
|
|
14
|
+
* (it can be a CSV of a visitor's own making), so it goes into textContent.
|
|
15
|
+
* - the host is started here. A visitor can land on this page directly, and the
|
|
16
|
+
* service worker is only registered by the application's boot loader - so this
|
|
17
|
+
* page registers it too, in the same shape, before it sends anything.
|
|
18
|
+
* - `window.SASjsMock.as()` is how a visitor becomes another account. It returns
|
|
19
|
+
* a real token pair, and the console sends it as a bearer token, so the
|
|
20
|
+
* refusal a restricted account gets is the refusal an application gets.
|
|
21
|
+
*/
|
|
22
|
+
;(function () {
|
|
23
|
+
'use strict'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The site's root, from the page's own URL: the console sits at `<base>/api/`,
|
|
27
|
+
* and half a URL (no trailing slash) is the shape a person types.
|
|
28
|
+
*/
|
|
29
|
+
const base = (function () {
|
|
30
|
+
const path = location.pathname.replace(/\/index\.html$/, '/')
|
|
31
|
+
const cut = /\/api\/?$/.exec(path)
|
|
32
|
+
return cut ? path.slice(0, cut.index + 1) : '/'
|
|
33
|
+
})()
|
|
34
|
+
|
|
35
|
+
const apiUrl = (path) => base + String(path).replace(/^\/+/, '')
|
|
36
|
+
const el = (id) => document.getElementById(id)
|
|
37
|
+
|
|
38
|
+
const TOKEN_KEY = 'mock-console-token'
|
|
39
|
+
|
|
40
|
+
/** The types the form treats as text rather than as JSON shapes. */
|
|
41
|
+
const SCALAR_TYPES = ['string', 'number', 'integer', 'boolean']
|
|
42
|
+
|
|
43
|
+
/** The parameter groups the form renders, in the order it renders them. */
|
|
44
|
+
const FORM_GROUPS = ['path', 'query', 'body']
|
|
45
|
+
|
|
46
|
+
const state = {
|
|
47
|
+
operations: [],
|
|
48
|
+
selected: null,
|
|
49
|
+
accounts: [],
|
|
50
|
+
admin: null,
|
|
51
|
+
/**
|
|
52
|
+
* The bearer token of the account the visitor chose to act as, if any.
|
|
53
|
+
*
|
|
54
|
+
* It has to survive the reload `as()` performs to apply the switch, so it
|
|
55
|
+
* lives in sessionStorage for the length of that navigation - the only place
|
|
56
|
+
* a page can keep something across its own restart. The worker applies the
|
|
57
|
+
* subject either way; the token is what makes the calls below carry the
|
|
58
|
+
* credential the API itself checks.
|
|
59
|
+
*/
|
|
60
|
+
token: null,
|
|
61
|
+
actingAs: null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -------------------------------------------------------------- the host
|
|
65
|
+
const loadScript = (src) =>
|
|
66
|
+
new Promise((resolve) => {
|
|
67
|
+
const script = document.createElement('script')
|
|
68
|
+
script.src = src
|
|
69
|
+
script.onload = resolve
|
|
70
|
+
script.onerror = () => resolve(false)
|
|
71
|
+
document.body.appendChild(script)
|
|
72
|
+
return true
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Starts the demo backend for this page.
|
|
77
|
+
*
|
|
78
|
+
* The application's boot loader registers the worker; a visitor who lands on
|
|
79
|
+
* `/api/` first has no loader, and every request would reach the static host
|
|
80
|
+
* and 404. So: register it beside the site, wait for it to control this page,
|
|
81
|
+
* then load `host/persist.js`, which is what hands the worker the drive this
|
|
82
|
+
* browser already has and keeps whatever this page changes.
|
|
83
|
+
*
|
|
84
|
+
* The reload guard is the loader's: a worker that has not claimed the page
|
|
85
|
+
* after a few seconds gets one reload, because a registration that completes
|
|
86
|
+
* after the navigation commits only controls the next one.
|
|
87
|
+
*/
|
|
88
|
+
const startHost = async () => {
|
|
89
|
+
// the property, not just its presence: an environment can expose
|
|
90
|
+
// `navigator.serviceWorker` as undefined, and `'serviceWorker' in navigator`
|
|
91
|
+
// is true for that
|
|
92
|
+
if (!navigator.serviceWorker) {
|
|
93
|
+
console.warn('[api console] service workers are unavailable')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// the loader on the app's page registers `<base>/sw.js?v=<version>`, and a
|
|
98
|
+
// second registration for the same scope under a different URL replaces it -
|
|
99
|
+
// so this asks for the same one, stamped the same way
|
|
100
|
+
const stamp = document.querySelector('meta[name="sasjs-mock-version"]')
|
|
101
|
+
const version = stamp && stamp.getAttribute('content')
|
|
102
|
+
const workerUrl =
|
|
103
|
+
new URL('sw.js', new URL(base, location.origin)).href +
|
|
104
|
+
(version && version !== '__MOCK_VERSION__' ? '?v=' + version : '')
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await navigator.serviceWorker.register(workerUrl, {
|
|
108
|
+
scope: base,
|
|
109
|
+
updateViaCache: 'none'
|
|
110
|
+
})
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error('[api console] the demo host could not register: ' + e)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!navigator.serviceWorker.controller) {
|
|
117
|
+
await new Promise((resolve) => {
|
|
118
|
+
const timer = setTimeout(resolve, 4000)
|
|
119
|
+
navigator.serviceWorker.addEventListener(
|
|
120
|
+
'controllerchange',
|
|
121
|
+
() => {
|
|
122
|
+
clearTimeout(timer)
|
|
123
|
+
resolve()
|
|
124
|
+
},
|
|
125
|
+
{ once: true }
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!navigator.serviceWorker.controller) {
|
|
131
|
+
if (!sessionStorage.getItem('mock-console-reloaded')) {
|
|
132
|
+
sessionStorage.setItem('mock-console-reloaded', '1')
|
|
133
|
+
location.reload()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
console.error(
|
|
137
|
+
'[api console] the demo host did not take control; calls may not answer'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await loadScript(apiUrl('host/persist.js'))
|
|
142
|
+
|
|
143
|
+
if (window.__sasjsMockReady) {
|
|
144
|
+
try {
|
|
145
|
+
await window.__sasjsMockReady
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error('[api console] the drive could not be handed over: ' + e)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ------------------------------------------------------------------ routes
|
|
153
|
+
const loadOperations = async () => {
|
|
154
|
+
const response = await fetch('operations.json', { cache: 'no-store' })
|
|
155
|
+
if (!response.ok) throw new Error('operations.json is ' + response.status)
|
|
156
|
+
state.operations = await response.json()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const groupOf = (operation) => operation.tag || 'Other'
|
|
160
|
+
|
|
161
|
+
const renderList = () => {
|
|
162
|
+
const filter = el('filter').value.trim().toLowerCase()
|
|
163
|
+
const list = el('routes')
|
|
164
|
+
list.textContent = ''
|
|
165
|
+
const matches = state.operations.filter((operation) => {
|
|
166
|
+
if (!filter) return true
|
|
167
|
+
return (
|
|
168
|
+
operation.path.toLowerCase().includes(filter) ||
|
|
169
|
+
operation.method.toLowerCase().includes(filter) ||
|
|
170
|
+
String(operation.summary || '')
|
|
171
|
+
.toLowerCase()
|
|
172
|
+
.includes(filter) ||
|
|
173
|
+
groupOf(operation).toLowerCase().includes(filter)
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if (!matches.length) {
|
|
178
|
+
const empty = document.createElement('p')
|
|
179
|
+
empty.className = 'hint'
|
|
180
|
+
empty.textContent = 'No route matches that.'
|
|
181
|
+
list.appendChild(empty)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const byTag = new Map()
|
|
186
|
+
for (const operation of matches) {
|
|
187
|
+
const tag = groupOf(operation)
|
|
188
|
+
if (!byTag.has(tag)) byTag.set(tag, [])
|
|
189
|
+
byTag.get(tag).push(operation)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const tag of [...byTag.keys()].sort()) {
|
|
193
|
+
const heading = document.createElement('h3')
|
|
194
|
+
heading.textContent = tag
|
|
195
|
+
list.appendChild(heading)
|
|
196
|
+
for (const operation of byTag.get(tag)) {
|
|
197
|
+
const button = document.createElement('button')
|
|
198
|
+
button.type = 'button'
|
|
199
|
+
button.className = 'route'
|
|
200
|
+
button.dataset.id = operation.id
|
|
201
|
+
if (state.selected && state.selected.id === operation.id)
|
|
202
|
+
button.classList.add('active')
|
|
203
|
+
const method = document.createElement('span')
|
|
204
|
+
method.className = 'method ' + String(operation.method).toLowerCase()
|
|
205
|
+
method.textContent = operation.method
|
|
206
|
+
const path = document.createElement('span')
|
|
207
|
+
path.className = 'path'
|
|
208
|
+
path.textContent = String(operation.path).replace(/^\/SASjsApi/, '')
|
|
209
|
+
button.appendChild(method)
|
|
210
|
+
button.appendChild(path)
|
|
211
|
+
button.title = operation.summary || operation.id
|
|
212
|
+
button.addEventListener('click', () => select(operation))
|
|
213
|
+
list.appendChild(button)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ------------------------------------------------------------------- forms
|
|
219
|
+
const fieldFor = (parameter, value) => {
|
|
220
|
+
const wrap = document.createElement('label')
|
|
221
|
+
wrap.className = 'field'
|
|
222
|
+
const name = document.createElement('span')
|
|
223
|
+
name.className = 'name'
|
|
224
|
+
name.textContent = parameter.name
|
|
225
|
+
const where = document.createElement('span')
|
|
226
|
+
where.className = 'where'
|
|
227
|
+
where.textContent = parameter.in + (parameter.required ? ', required' : '')
|
|
228
|
+
wrap.appendChild(name)
|
|
229
|
+
wrap.appendChild(where)
|
|
230
|
+
|
|
231
|
+
let input
|
|
232
|
+
if (parameter.type === 'boolean') {
|
|
233
|
+
// three states rather than a checkbox, because the API reads "absent" and
|
|
234
|
+
// "false" differently: every boolean in the route table is optional, and an
|
|
235
|
+
// untouched `isAdmin` on PATCH /SASjsApi/user/{uid} would send `false` and
|
|
236
|
+
// demote the account it was meant to leave alone
|
|
237
|
+
input = document.createElement('select')
|
|
238
|
+
for (const [label, optionValue] of [
|
|
239
|
+
['leave unset', ''],
|
|
240
|
+
['true', 'true'],
|
|
241
|
+
['false', 'false']
|
|
242
|
+
]) {
|
|
243
|
+
const option = document.createElement('option')
|
|
244
|
+
option.value = optionValue
|
|
245
|
+
option.textContent = label
|
|
246
|
+
input.appendChild(option)
|
|
247
|
+
}
|
|
248
|
+
if (value === true) input.value = 'true'
|
|
249
|
+
if (value === false) input.value = 'false'
|
|
250
|
+
} else {
|
|
251
|
+
input = document.createElement('input')
|
|
252
|
+
input.type = 'text'
|
|
253
|
+
input.value = value === undefined ? '' : String(value)
|
|
254
|
+
if (parameter.type && parameter.type !== 'string')
|
|
255
|
+
input.placeholder = parameter.type
|
|
256
|
+
}
|
|
257
|
+
input.dataset.name = parameter.name
|
|
258
|
+
input.dataset.in = parameter.in
|
|
259
|
+
input.dataset.type = parameter.type || 'string'
|
|
260
|
+
// no `required` hint: nothing submits this form, so constraint
|
|
261
|
+
// validation never runs - `send()` checks the required fields instead
|
|
262
|
+
wrap.appendChild(input)
|
|
263
|
+
return wrap
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A value to start a field with.
|
|
268
|
+
*
|
|
269
|
+
* The login route is prefilled from the fixture the build shipped: the demo
|
|
270
|
+
* accounts are published so a visitor can recover from a logout, and typing
|
|
271
|
+
* them from a page that already lists them is busywork.
|
|
272
|
+
*/
|
|
273
|
+
const initialValue = (operation, parameter) => {
|
|
274
|
+
if (operation.id !== 'POST /SASLogon/login' || !state.admin)
|
|
275
|
+
return undefined
|
|
276
|
+
if (parameter.name === 'username') return state.admin.username
|
|
277
|
+
if (parameter.name === 'password') return state.admin.password
|
|
278
|
+
return undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const select = (operation) => {
|
|
282
|
+
state.selected = operation
|
|
283
|
+
// the route goes in the URL: acting as another account reloads the page (the
|
|
284
|
+
// host restarts so every screen redraws), and a visitor should come back to
|
|
285
|
+
// the route they were trying rather than to an empty console
|
|
286
|
+
try {
|
|
287
|
+
history.replaceState(
|
|
288
|
+
null,
|
|
289
|
+
'',
|
|
290
|
+
'?route=' + encodeURIComponent(operation.id)
|
|
291
|
+
)
|
|
292
|
+
} catch (e) {
|
|
293
|
+
/* a page that cannot rewrite its URL still works */
|
|
294
|
+
}
|
|
295
|
+
renderList()
|
|
296
|
+
el('empty').hidden = true
|
|
297
|
+
el('route').hidden = false
|
|
298
|
+
el('title').textContent = operation.method + ' ' + operation.path
|
|
299
|
+
el('summary').textContent =
|
|
300
|
+
(operation.summary || '') +
|
|
301
|
+
(operation.implemented === false
|
|
302
|
+
? ' (this host does not implement this route)'
|
|
303
|
+
: '')
|
|
304
|
+
|
|
305
|
+
const form = el('form')
|
|
306
|
+
form.textContent = ''
|
|
307
|
+
const params = operation.params || []
|
|
308
|
+
let rendered = 0
|
|
309
|
+
for (const where of FORM_GROUPS) {
|
|
310
|
+
const inGroup = params.filter((parameter) => parameter.in === where)
|
|
311
|
+
if (!inGroup.length) continue
|
|
312
|
+
const fieldset = document.createElement('fieldset')
|
|
313
|
+
const legend = document.createElement('legend')
|
|
314
|
+
legend.textContent = where === 'body' ? 'JSON body' : where
|
|
315
|
+
fieldset.appendChild(legend)
|
|
316
|
+
for (const parameter of inGroup) {
|
|
317
|
+
fieldset.appendChild(
|
|
318
|
+
fieldFor(parameter, initialValue(operation, parameter))
|
|
319
|
+
)
|
|
320
|
+
rendered++
|
|
321
|
+
}
|
|
322
|
+
form.appendChild(fieldset)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!rendered) {
|
|
326
|
+
const none = document.createElement('p')
|
|
327
|
+
none.className = 'hint'
|
|
328
|
+
none.textContent = 'This route takes no parameters.'
|
|
329
|
+
form.appendChild(none)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if ((operation.requestBodies || []).includes('multipart/form-data')) {
|
|
333
|
+
const note = document.createElement('p')
|
|
334
|
+
note.className = 'hint warn'
|
|
335
|
+
note.textContent =
|
|
336
|
+
'This route takes a file upload, which this console does not send. Use the application, or a client of your own.'
|
|
337
|
+
form.appendChild(note)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
el('response').hidden = true
|
|
341
|
+
el('timing').textContent = ''
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ------------------------------------------------------------------ sending
|
|
345
|
+
const valuesOf = () => {
|
|
346
|
+
const values = { path: {}, query: {}, body: {} }
|
|
347
|
+
for (const input of el('form').querySelectorAll('[data-name]')) {
|
|
348
|
+
const name = input.dataset.name
|
|
349
|
+
const where = input.dataset.in
|
|
350
|
+
const type = input.dataset.type
|
|
351
|
+
let value
|
|
352
|
+
if (type === 'boolean') {
|
|
353
|
+
// untouched means absent: an optional boolean the visitor never chose is
|
|
354
|
+
// left out of the body rather than sent as `false`
|
|
355
|
+
if (String(input.value) === '') continue
|
|
356
|
+
value = input.value === 'true'
|
|
357
|
+
} else {
|
|
358
|
+
if (String(input.value).trim() === '') continue
|
|
359
|
+
value = input.value
|
|
360
|
+
if (type === 'number' || type === 'integer') {
|
|
361
|
+
value = Number(value)
|
|
362
|
+
// JSON.stringify writes NaN as null, so a typo would reach the host as
|
|
363
|
+
// a missing field and its refusal would read as a host fault
|
|
364
|
+
if (!Number.isFinite(value)) {
|
|
365
|
+
const err = new Error(name + ' must be a number')
|
|
366
|
+
err.invalid = true
|
|
367
|
+
throw err
|
|
368
|
+
}
|
|
369
|
+
} else if (!SCALAR_TYPES.includes(type)) {
|
|
370
|
+
// A named, non-scalar type is a shape: `FileTree` on POST
|
|
371
|
+
// /SASjsApi/drive/deploy is refused by the package when it arrives as a
|
|
372
|
+
// string ("Provided not supported data format"). An enum (`RunTimeType`,
|
|
373
|
+
// `PermissionSettingForRoute`) is a plain word and never parses to an
|
|
374
|
+
// object, so it stays the string that was typed. `object` and `array`
|
|
375
|
+
// insist on valid JSON.
|
|
376
|
+
let parsed
|
|
377
|
+
let shape = false
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(value)
|
|
380
|
+
shape = parsed !== null && typeof parsed === 'object'
|
|
381
|
+
} catch (e) {
|
|
382
|
+
if (type === 'object' || type === 'array') {
|
|
383
|
+
const err = new Error(name + ' must be JSON: ' + e.message)
|
|
384
|
+
err.invalid = true
|
|
385
|
+
throw err
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (shape) value = parsed
|
|
389
|
+
else if (type === 'object' || type === 'array') {
|
|
390
|
+
const err = new Error(name + ' must be JSON')
|
|
391
|
+
err.invalid = true
|
|
392
|
+
throw err
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
values[where][name] = value
|
|
397
|
+
}
|
|
398
|
+
return values
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const buildUrl = (operation, values) => {
|
|
402
|
+
let path = operation.path
|
|
403
|
+
for (const [name, value] of Object.entries(values.path))
|
|
404
|
+
path = path.replace('{' + name + '}', encodeURIComponent(value))
|
|
405
|
+
const url = apiUrl(path)
|
|
406
|
+
const search = new URLSearchParams()
|
|
407
|
+
for (const [name, value] of Object.entries(values.query))
|
|
408
|
+
// an object or array on the query goes as JSON: `append` would write
|
|
409
|
+
// `[object Object]` (or a comma-joined array) and the host's 400 would read
|
|
410
|
+
// as its fault
|
|
411
|
+
search.append(
|
|
412
|
+
name,
|
|
413
|
+
value !== null && typeof value === 'object'
|
|
414
|
+
? JSON.stringify(value)
|
|
415
|
+
: value
|
|
416
|
+
)
|
|
417
|
+
const text = search.toString()
|
|
418
|
+
return text ? url + '?' + text : url
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const send = async () => {
|
|
422
|
+
const operation = state.selected
|
|
423
|
+
if (!operation) return
|
|
424
|
+
el('send').disabled = true
|
|
425
|
+
el('timing').textContent = 'sending...'
|
|
426
|
+
const started = performance.now()
|
|
427
|
+
try {
|
|
428
|
+
const values = valuesOf()
|
|
429
|
+
// a blank required parameter would otherwise be sent as the placeholder
|
|
430
|
+
// itself - a literal `{sessionId}` in the path - and the 404 that came back
|
|
431
|
+
// would look like the host's fault
|
|
432
|
+
const missing = (operation.params || [])
|
|
433
|
+
// a form-only field (`formData`, an upload) has no input on this page, so
|
|
434
|
+
// requiring it would ask for something the visitor cannot give
|
|
435
|
+
.filter((parameter) => FORM_GROUPS.includes(parameter.in))
|
|
436
|
+
.filter((parameter) => parameter.required)
|
|
437
|
+
.filter(
|
|
438
|
+
(parameter) =>
|
|
439
|
+
!values[parameter.in] ||
|
|
440
|
+
values[parameter.in][parameter.name] === undefined
|
|
441
|
+
)
|
|
442
|
+
.map((parameter) => parameter.name)
|
|
443
|
+
if (missing.length) {
|
|
444
|
+
el('status').textContent = 'missing input'
|
|
445
|
+
el('status').className = 'status bad'
|
|
446
|
+
el('timing').textContent = ''
|
|
447
|
+
el('body').textContent =
|
|
448
|
+
'This route needs ' +
|
|
449
|
+
missing.join(', ') +
|
|
450
|
+
'. Fill the field' +
|
|
451
|
+
(missing.length > 1 ? 's' : '') +
|
|
452
|
+
' in and send again.'
|
|
453
|
+
el('response').hidden = false
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
const url = buildUrl(operation, values)
|
|
457
|
+
const init = { method: operation.method, headers: {} }
|
|
458
|
+
if (state.token) init.headers.authorization = 'Bearer ' + state.token
|
|
459
|
+
// a GET or HEAD with a body is refused by fetch itself, and the TypeError
|
|
460
|
+
// would read as the host's answer
|
|
461
|
+
const carriesBody =
|
|
462
|
+
operation.method !== 'GET' &&
|
|
463
|
+
operation.method !== 'HEAD' &&
|
|
464
|
+
Object.keys(values.body).length > 0
|
|
465
|
+
if (carriesBody) {
|
|
466
|
+
init.headers['content-type'] = 'application/json'
|
|
467
|
+
init.body = JSON.stringify(values.body)
|
|
468
|
+
}
|
|
469
|
+
const response = await fetch(url, init)
|
|
470
|
+
const elapsed = Math.round(performance.now() - started)
|
|
471
|
+
const text = await response.text()
|
|
472
|
+
el('status').textContent = response.status + ' ' + response.statusText
|
|
473
|
+
el('status').className = 'status ' + (response.ok ? 'ok' : 'bad')
|
|
474
|
+
el('timing').textContent =
|
|
475
|
+
elapsed +
|
|
476
|
+
'ms ' +
|
|
477
|
+
'as ' +
|
|
478
|
+
(state.actingAs || (state.admin ? state.admin.username : 'this host'))
|
|
479
|
+
el('body').textContent = prettyOf(
|
|
480
|
+
text,
|
|
481
|
+
response.headers.get('content-type')
|
|
482
|
+
)
|
|
483
|
+
el('response').hidden = false
|
|
484
|
+
} catch (e) {
|
|
485
|
+
// a value this page refused is the visitor's input, not the host's answer
|
|
486
|
+
el('status').textContent =
|
|
487
|
+
e && e.invalid ? 'invalid input' : 'request failed'
|
|
488
|
+
el('status').className = 'status bad'
|
|
489
|
+
el('body').textContent = (e && e.message) || String(e)
|
|
490
|
+
el('response').hidden = false
|
|
491
|
+
el('timing').textContent = ''
|
|
492
|
+
} finally {
|
|
493
|
+
el('send').disabled = false
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const prettyOf = (text, contentType) => {
|
|
498
|
+
if (!text) return '(empty body)'
|
|
499
|
+
if ((contentType || '').includes('json')) {
|
|
500
|
+
try {
|
|
501
|
+
return JSON.stringify(JSON.parse(text), null, 2)
|
|
502
|
+
} catch (e) {
|
|
503
|
+
/* not JSON after all: show it as it came */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return text.length > 20000
|
|
507
|
+
? text.slice(0, 20000) +
|
|
508
|
+
'\n... (' +
|
|
509
|
+
(text.length - 20000) +
|
|
510
|
+
' more bytes)'
|
|
511
|
+
: text
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------- accounts
|
|
515
|
+
/**
|
|
516
|
+
* The accounts the build shipped, shown so a visitor can sign in to the
|
|
517
|
+
* application and knows how to get back in after logging out.
|
|
518
|
+
*
|
|
519
|
+
* They are not a secret: `host/identity.json` is served by the site, and a
|
|
520
|
+
* browser-hosted build has no login wall to protect. A project that ships its
|
|
521
|
+
* own fixture sees its own accounts here.
|
|
522
|
+
*/
|
|
523
|
+
const loadAccounts = async () => {
|
|
524
|
+
try {
|
|
525
|
+
const response = await fetch(apiUrl('host/identity.json'), {
|
|
526
|
+
cache: 'no-store'
|
|
527
|
+
})
|
|
528
|
+
if (!response.ok) return
|
|
529
|
+
const fixture = await response.json()
|
|
530
|
+
state.accounts = (fixture && fixture.users) || []
|
|
531
|
+
// the account whose credentials are published and offered back: an admin if
|
|
532
|
+
// the fixture ships one, otherwise the first account - and the page says
|
|
533
|
+
// which, because a fixture without an admin is a project's choice and its
|
|
534
|
+
// first user is not one
|
|
535
|
+
const administrator = state.accounts.find((user) => user.isAdmin)
|
|
536
|
+
state.admin = administrator || state.accounts[0] || null
|
|
537
|
+
state.adminIsAdministrator = Boolean(administrator)
|
|
538
|
+
await adoptStoredSubject()
|
|
539
|
+
setWho()
|
|
540
|
+
renderAccounts()
|
|
541
|
+
} catch (e) {
|
|
542
|
+
/* a site with no fixture simply shows nothing here */
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The account this page is already answered as.
|
|
548
|
+
*
|
|
549
|
+
* `as()` reloads the page and stores the subject, and the worker answers as it
|
|
550
|
+
* from then on - so the console has to read that back, or it would label a
|
|
551
|
+
* restricted session as the administrator.
|
|
552
|
+
*/
|
|
553
|
+
const adoptStoredSubject = async () => {
|
|
554
|
+
if (!window.SASjsMock || !window.SASjsMock.identity) return
|
|
555
|
+
try {
|
|
556
|
+
const stored = await window.SASjsMock.identity()
|
|
557
|
+
const subject = stored && stored.subject
|
|
558
|
+
if (subject && (!state.admin || subject !== state.admin.username))
|
|
559
|
+
state.actingAs = subject
|
|
560
|
+
} catch (e) {
|
|
561
|
+
/* no stored identity: the host answers as its fixture administrator */
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const setWho = () => {
|
|
566
|
+
const who = el('who')
|
|
567
|
+
if (state.actingAs) {
|
|
568
|
+
who.textContent = 'acting as ' + state.actingAs
|
|
569
|
+
who.classList.add('acting')
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
who.classList.remove('acting')
|
|
573
|
+
who.textContent = state.admin
|
|
574
|
+
? 'answering as ' + state.admin.username
|
|
575
|
+
: 'connected'
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const actAs = async (username) => {
|
|
579
|
+
if (!window.SASjsMock || !window.SASjsMock.as) return
|
|
580
|
+
try {
|
|
581
|
+
const pair = await window.SASjsMock.as(username)
|
|
582
|
+
// `as()` schedules the reload on a 0ms timer and resolves first, so this
|
|
583
|
+
// runs before the page goes away: storing the pair here is what makes the
|
|
584
|
+
// calls on the far side carry a credential
|
|
585
|
+
const target = username || (state.admin && state.admin.username) || null
|
|
586
|
+
// returning to the administrator is not "acting as" anyone: the host
|
|
587
|
+
// answers as them already, and a header would just be a credential for the
|
|
588
|
+
// account the fixture starts with
|
|
589
|
+
const backToAdmin = Boolean(
|
|
590
|
+
state.admin && target === state.admin.username
|
|
591
|
+
)
|
|
592
|
+
const token =
|
|
593
|
+
!backToAdmin && pair && pair.accessToken ? pair.accessToken : null
|
|
594
|
+
try {
|
|
595
|
+
if (token) sessionStorage.setItem(TOKEN_KEY, token)
|
|
596
|
+
else sessionStorage.removeItem(TOKEN_KEY)
|
|
597
|
+
} catch (e) {
|
|
598
|
+
/* a page that refuses storage still has the worker's subject */
|
|
599
|
+
}
|
|
600
|
+
state.token = token
|
|
601
|
+
state.actingAs = backToAdmin ? null : target
|
|
602
|
+
} catch (e) {
|
|
603
|
+
state.token = null
|
|
604
|
+
state.actingAs = null
|
|
605
|
+
alert('Could not act as that account: ' + e.message)
|
|
606
|
+
}
|
|
607
|
+
setWho()
|
|
608
|
+
renderAccounts()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const renderAccounts = () => {
|
|
612
|
+
const host = el('accounts')
|
|
613
|
+
if (!host || !state.accounts.length) return
|
|
614
|
+
host.textContent = ''
|
|
615
|
+
|
|
616
|
+
const heading = document.createElement('h3')
|
|
617
|
+
heading.textContent = 'Demo accounts'
|
|
618
|
+
host.appendChild(heading)
|
|
619
|
+
const note = document.createElement('p')
|
|
620
|
+
note.className = 'hint'
|
|
621
|
+
note.textContent =
|
|
622
|
+
'This is a browser demo, so these are published on purpose. Use them to ' +
|
|
623
|
+
'sign in to the application. "Act as" sends every call below with that ' +
|
|
624
|
+
"account's token, which is how the refusals a restricted user gets are shown."
|
|
625
|
+
if (!state.adminIsAdministrator && state.admin)
|
|
626
|
+
// the fixture decides who exists; saying which account is standing in keeps
|
|
627
|
+
// the page from calling an ordinary user an administrator
|
|
628
|
+
note.textContent +=
|
|
629
|
+
' This fixture ships no administrator, so the page names ' +
|
|
630
|
+
state.admin.username +
|
|
631
|
+
' wherever it would say "the demo administrator".'
|
|
632
|
+
host.appendChild(note)
|
|
633
|
+
|
|
634
|
+
const table = document.createElement('table')
|
|
635
|
+
for (const user of state.accounts) {
|
|
636
|
+
const row = document.createElement('tr')
|
|
637
|
+
const name = document.createElement('td')
|
|
638
|
+
name.textContent = user.username
|
|
639
|
+
const password = document.createElement('td')
|
|
640
|
+
password.textContent = user.password || '(none)'
|
|
641
|
+
const role = document.createElement('td')
|
|
642
|
+
role.textContent = user.isAdmin ? 'admin' : 'user'
|
|
643
|
+
const action = document.createElement('td')
|
|
644
|
+
const button = document.createElement('button')
|
|
645
|
+
button.type = 'button'
|
|
646
|
+
button.className = 'link'
|
|
647
|
+
button.dataset.user = user.username
|
|
648
|
+
const isCurrent = state.actingAs === user.username
|
|
649
|
+
button.textContent = isCurrent ? 'acting as this' : 'Act as'
|
|
650
|
+
button.disabled = isCurrent
|
|
651
|
+
button.addEventListener('click', () => actAs(user.username))
|
|
652
|
+
action.appendChild(button)
|
|
653
|
+
row.appendChild(name)
|
|
654
|
+
row.appendChild(password)
|
|
655
|
+
row.appendChild(role)
|
|
656
|
+
row.appendChild(action)
|
|
657
|
+
table.appendChild(row)
|
|
658
|
+
}
|
|
659
|
+
host.appendChild(table)
|
|
660
|
+
|
|
661
|
+
if (state.actingAs) {
|
|
662
|
+
const back = document.createElement('button')
|
|
663
|
+
back.type = 'button'
|
|
664
|
+
back.className = 'link'
|
|
665
|
+
back.textContent = state.adminIsAdministrator
|
|
666
|
+
? 'Back to the demo administrator'
|
|
667
|
+
: 'Back to ' + (state.admin ? state.admin.username : 'this host')
|
|
668
|
+
back.addEventListener('click', () => actAs())
|
|
669
|
+
host.appendChild(back)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ------------------------------------------------------------------ restart
|
|
674
|
+
/**
|
|
675
|
+
* Deletes the demo's database, and waits for the delete to finish.
|
|
676
|
+
*
|
|
677
|
+
* A delete blocked by an open connection - the host keeps one while a request
|
|
678
|
+
* runs - completes after whatever the page does next, so a page that navigates
|
|
679
|
+
* immediately comes back to the drive it asked to clear.
|
|
680
|
+
*/
|
|
681
|
+
const deleteDriveDatabase = () =>
|
|
682
|
+
new Promise((resolve, reject) => {
|
|
683
|
+
const request = indexedDB.deleteDatabase('sasjs-mock-host')
|
|
684
|
+
request.onsuccess = () => resolve()
|
|
685
|
+
request.onerror = () =>
|
|
686
|
+
reject(request.error || new Error('the drive could not be deleted'))
|
|
687
|
+
request.onblocked = () =>
|
|
688
|
+
reject(
|
|
689
|
+
new Error(
|
|
690
|
+
'the demo host still holds the drive; close its other tabs and try again'
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* `?reset=1` starts the demo again from the state the build ships.
|
|
697
|
+
*
|
|
698
|
+
* The drive lives in IndexedDB, and the worker is what reseeds it, so the
|
|
699
|
+
* honest reset is the host's own: `SASjsMock.reset()` clears the stored drive
|
|
700
|
+
* and seeds it from the snapshot, then the page reloads onto a clean console. A
|
|
701
|
+
* browser's "hard refresh" does NOT do this - it bypasses the HTTP cache only -
|
|
702
|
+
* which is why there is a link for it.
|
|
703
|
+
*/
|
|
704
|
+
const resetIfAsked = async () => {
|
|
705
|
+
// `=1` exactly: the page tells the visitor `?reset=1`, and `?reset=0` is the
|
|
706
|
+
// one thing someone will type to say "not now"
|
|
707
|
+
if (new URLSearchParams(location.search).get('reset') !== '1') return false
|
|
708
|
+
try {
|
|
709
|
+
if (window.SASjsMock && window.SASjsMock.reset)
|
|
710
|
+
await window.SASjsMock.reset()
|
|
711
|
+
else await deleteDriveDatabase()
|
|
712
|
+
sessionStorage.clear()
|
|
713
|
+
localStorage.removeItem('sasjs-mock-build')
|
|
714
|
+
} catch (e) {
|
|
715
|
+
// the drive is unchanged, so replacing the page would come straight back to
|
|
716
|
+
// it and look like the flag did nothing
|
|
717
|
+
console.error('[api console] reset failed: ' + e)
|
|
718
|
+
el('routes').textContent =
|
|
719
|
+
'The demo could not be reset: ' + ((e && e.message) || e)
|
|
720
|
+
return true
|
|
721
|
+
}
|
|
722
|
+
location.replace(base + 'api/')
|
|
723
|
+
return true
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ------------------------------------------------------------------- start
|
|
727
|
+
const start = async () => {
|
|
728
|
+
try {
|
|
729
|
+
state.token = sessionStorage.getItem(TOKEN_KEY) || null
|
|
730
|
+
} catch (e) {
|
|
731
|
+
state.token = null
|
|
732
|
+
}
|
|
733
|
+
el('filter').addEventListener('input', renderList)
|
|
734
|
+
el('send').addEventListener('click', send)
|
|
735
|
+
el('copy').addEventListener('click', async () => {
|
|
736
|
+
try {
|
|
737
|
+
await navigator.clipboard.writeText(el('body').textContent)
|
|
738
|
+
el('copy').textContent = 'Copied'
|
|
739
|
+
setTimeout(() => (el('copy').textContent = 'Copy'), 1200)
|
|
740
|
+
} catch (e) {
|
|
741
|
+
/* clipboard refused: the text is on screen and selectable */
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
await startHost()
|
|
746
|
+
if (await resetIfAsked()) return
|
|
747
|
+
|
|
748
|
+
await Promise.all([loadOperations(), loadAccounts()])
|
|
749
|
+
renderList()
|
|
750
|
+
|
|
751
|
+
const wanted = new URLSearchParams(location.search).get('route')
|
|
752
|
+
const chosen = wanted
|
|
753
|
+
? state.operations.find((operation) => operation.id === wanted)
|
|
754
|
+
: undefined
|
|
755
|
+
if (chosen) select(chosen)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
start().catch((e) => {
|
|
759
|
+
const list = el('routes')
|
|
760
|
+
if (list) list.textContent = 'The console could not start: ' + e.message
|
|
761
|
+
})
|
|
762
|
+
})()
|