@specbook/ui 0.1.0 → 0.3.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/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
- package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +7 -5
- package/src/app.tsx +71 -13
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +24 -4
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +287 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +416 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +14 -1
- package/src/nav.ts +12 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +195 -0
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -8
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +167 -0
- package/src/panel/Panel.tsx +50 -13
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +93 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +43 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +277 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +9 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +48 -3
- package/src/viewer/worker.ts +20 -7
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
- package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
- package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
- package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
- package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
- package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
- package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
package/src/embed.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { parseTarget, type Target } from './embed/target'
|
|
2
|
+
import { followTheme } from './embed/theme'
|
|
2
3
|
import tokens from './styles/tokens.css?inline'
|
|
3
4
|
|
|
4
5
|
const css = `button {
|
|
@@ -12,14 +13,27 @@ const css = `button {
|
|
|
12
13
|
color: var(--sb-accent);
|
|
13
14
|
}
|
|
14
15
|
button:hover { background: var(--sb-surface-raised); }
|
|
15
|
-
button:focus-visible { outline: 2px solid var(--sb-focus-ring); }
|
|
16
|
+
button:focus-visible { outline: 2px solid var(--sb-focus-ring); }
|
|
17
|
+
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }`
|
|
18
|
+
|
|
19
|
+
let opened = false
|
|
16
20
|
|
|
17
21
|
/** The panel (editor, grammars, runner) is a separate chunk fetched on first click. */
|
|
18
|
-
async function open(target: Target) {
|
|
22
|
+
export async function open(target: Target, trigger?: HTMLElement): Promise<void> {
|
|
19
23
|
const { show } = await import('./embed/panel')
|
|
20
|
-
|
|
24
|
+
opened = true
|
|
25
|
+
show(target, trigger)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Closes the panel, if it is open. */
|
|
29
|
+
export async function close(): Promise<void> {
|
|
30
|
+
if (!opened) return
|
|
31
|
+
const { close: closePanel } = await import('./embed/panel')
|
|
32
|
+
closePanel()
|
|
21
33
|
}
|
|
22
34
|
|
|
35
|
+
const nameOf = (t: Target) => t.sequence ?? t.request ?? t.operation ?? ''
|
|
36
|
+
|
|
23
37
|
class SpecbookPlay extends HTMLElement {
|
|
24
38
|
target: Target = {}
|
|
25
39
|
|
|
@@ -33,29 +47,44 @@ class SpecbookPlay extends HTMLElement {
|
|
|
33
47
|
b.type = 'button'
|
|
34
48
|
b.part.add('play')
|
|
35
49
|
b.textContent = '▶ Play'
|
|
36
|
-
b.
|
|
50
|
+
b.setAttribute('aria-haspopup', 'dialog')
|
|
51
|
+
b.setAttribute('aria-expanded', 'false')
|
|
52
|
+
b.setAttribute('aria-label', `Play ${nameOf(this.target)}`.trim())
|
|
53
|
+
b.addEventListener('click', () => open(this.target, this))
|
|
37
54
|
root.append(b)
|
|
55
|
+
followTheme(this, this.target.theme)
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
customElements.define('specbook-play', SpecbookPlay)
|
|
42
60
|
|
|
61
|
+
/** A button or a link that carries the attribute is the trigger itself; anything else gets a Play button beside it. */
|
|
62
|
+
const isTrigger = (el: HTMLElement) => el.matches('button, a[href], [role="button"]')
|
|
63
|
+
|
|
43
64
|
function attach(el: HTMLElement) {
|
|
44
|
-
if (el.dataset.specbookReady) return
|
|
65
|
+
if (el.dataset.specbookReady !== undefined) return
|
|
45
66
|
el.dataset.specbookReady = ''
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
const target = parseTarget(el)
|
|
68
|
+
if (!target) return
|
|
69
|
+
if (isTrigger(el)) {
|
|
70
|
+
el.setAttribute('aria-haspopup', 'dialog')
|
|
71
|
+
if (!el.hasAttribute('aria-expanded')) el.setAttribute('aria-expanded', 'false')
|
|
72
|
+
el.addEventListener('click', (e) => {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
open(target, el)
|
|
75
|
+
})
|
|
76
|
+
return
|
|
53
77
|
}
|
|
78
|
+
const play = document.createElement('specbook-play') as SpecbookPlay
|
|
79
|
+
play.target = target
|
|
54
80
|
el.append(play)
|
|
55
81
|
}
|
|
56
82
|
|
|
57
|
-
const
|
|
58
|
-
|
|
83
|
+
const SELECTOR = ['try', 'flow']
|
|
84
|
+
.map((a) => `[data-specbook-${a}]:not([data-specbook-ready])`)
|
|
85
|
+
.join(',')
|
|
86
|
+
|
|
87
|
+
const scan = (root: ParentNode) => root.querySelectorAll<HTMLElement>(SELECTOR).forEach(attach)
|
|
59
88
|
|
|
60
89
|
scan(document)
|
|
61
90
|
// SPA docs add content after load
|
package/src/engine.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
// Run orchestration for the UI. Pure TS (no DOM) so it can be unit-tested with a fake registry.
|
|
2
2
|
import {
|
|
3
|
+
AddressError,
|
|
3
4
|
type AuthProfile,
|
|
5
|
+
absoluteBase,
|
|
6
|
+
addressUses,
|
|
7
|
+
baseUrlFor,
|
|
4
8
|
createFetchTransport,
|
|
5
9
|
createMockTransport,
|
|
10
|
+
type DiscoveredEndpoints,
|
|
6
11
|
deepMerge,
|
|
7
12
|
type Example,
|
|
13
|
+
environmentNames,
|
|
14
|
+
environmentScope,
|
|
8
15
|
installFaker,
|
|
16
|
+
isDiscovery,
|
|
17
|
+
type OAuthSettings,
|
|
9
18
|
type OpMeta,
|
|
19
|
+
oauthFor,
|
|
20
|
+
parseDiscovery,
|
|
10
21
|
prepareFaker,
|
|
11
22
|
type RequestDef,
|
|
12
23
|
type RunEvent,
|
|
@@ -17,11 +28,24 @@ import {
|
|
|
17
28
|
type SpecbookConfig,
|
|
18
29
|
type Transport,
|
|
19
30
|
usesFaker,
|
|
31
|
+
withMock,
|
|
20
32
|
} from '@specbook/core'
|
|
33
|
+
import type { EnvironmentStore } from './custom-environments'
|
|
21
34
|
import { type ExtensionBridge, extensionTransport } from './extension'
|
|
22
|
-
import {
|
|
35
|
+
import { routingTransport, withPageSecrets } from './routing'
|
|
23
36
|
import { originOf, type SecretStore, secretStore } from './secrets'
|
|
24
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The reserved service name a sign-in's token exchange is addressed to. The dev server's proxy answers
|
|
40
|
+
* `auth:<profile>` from the profile's own `oauth.tokenUrl` (the same prefix lives in `@specbook/vite`).
|
|
41
|
+
*/
|
|
42
|
+
const AUTH_SERVICE_PREFIX = 'auth:'
|
|
43
|
+
const authService = (profile: string) => `${AUTH_SERVICE_PREFIX}${encodeURIComponent(profile)}`
|
|
44
|
+
/** Likewise for an OpenID Connect discovery document, whose host may differ from the token endpoint's. */
|
|
45
|
+
const DISCOVERY_SERVICE_PREFIX = 'discovery:'
|
|
46
|
+
const discoveryService = (profile: string) =>
|
|
47
|
+
`${DISCOVERY_SERVICE_PREFIX}${encodeURIComponent(profile)}`
|
|
48
|
+
|
|
25
49
|
export interface ExampleEntry {
|
|
26
50
|
name: string
|
|
27
51
|
source: 'generated' | 'project'
|
|
@@ -61,7 +85,7 @@ export interface RunInput {
|
|
|
61
85
|
version: number
|
|
62
86
|
/** Forced mock status per run (mock env only). */
|
|
63
87
|
mockStatus?: number
|
|
64
|
-
|
|
88
|
+
inputs?: Record<string, unknown>
|
|
65
89
|
vars?: Record<string, unknown>
|
|
66
90
|
onEvent?: (e: RunEvent) => void
|
|
67
91
|
onInput?: Parameters<typeof runSequence>[1]['onInput']
|
|
@@ -73,13 +97,46 @@ export interface RunInput {
|
|
|
73
97
|
fileDir?: string
|
|
74
98
|
}
|
|
75
99
|
|
|
100
|
+
/** One variable of an environment as the settings show it. */
|
|
101
|
+
export interface VariableRow {
|
|
102
|
+
name: string
|
|
103
|
+
value: string
|
|
104
|
+
/** where the value comes from: the config's defaults or the environment's section, a file, this browser */
|
|
105
|
+
source: 'default' | 'config' | 'file' | 'browser'
|
|
106
|
+
/** what the variable falls back to when its own value is removed; undefined when it has no other */
|
|
107
|
+
fallback?: string
|
|
108
|
+
/** whether the value can be edited here */
|
|
109
|
+
editable: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface EnvironmentView {
|
|
113
|
+
rows: VariableRow[]
|
|
114
|
+
/** where each service's calls go in this environment, or why they cannot */
|
|
115
|
+
addresses: { service: string; url?: string; error?: string }[]
|
|
116
|
+
/** which services' addresses and profiles' sign-in fields read each variable */
|
|
117
|
+
uses: ReturnType<typeof addressUses>
|
|
118
|
+
/** a browser-made environment: it can be deleted */
|
|
119
|
+
removable: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
76
122
|
export interface EngineOptions {
|
|
77
123
|
/** The bridge to the browser extension when it is installed and enabled for this page. */
|
|
78
124
|
extension?: () => Promise<ExtensionBridge | undefined>
|
|
79
125
|
/** Where the person's typed secrets are kept (the page's store by default). */
|
|
80
126
|
secrets?: SecretStore
|
|
127
|
+
/** Environments made in this browser (a static page has no files to write them to). */
|
|
128
|
+
environmentStore?: EnvironmentStore
|
|
81
129
|
}
|
|
82
130
|
|
|
131
|
+
const ENV_NAME = /^[A-Za-z0-9_-]+$/
|
|
132
|
+
const VAR_NAME = /^[A-Za-z_][\w.-]*$/
|
|
133
|
+
const hasTemplate = (v: unknown): boolean =>
|
|
134
|
+
typeof v === 'string'
|
|
135
|
+
? /\{\{/.test(v)
|
|
136
|
+
: v !== null && typeof v === 'object' && Object.values(v).some(hasTemplate)
|
|
137
|
+
const text = (v: unknown) =>
|
|
138
|
+
v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '')
|
|
139
|
+
|
|
83
140
|
export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
84
141
|
const mockLoader = {
|
|
85
142
|
statuses: (service: string, opId: string) =>
|
|
@@ -95,6 +152,11 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
95
152
|
return (await reg.loadConfig(version)).default as SpecbookConfig
|
|
96
153
|
}
|
|
97
154
|
|
|
155
|
+
const isStaticPage = () => reg.mode !== 'dev'
|
|
156
|
+
const browserEnvs = () => (isStaticPage() ? (opts.environmentStore?.all() ?? {}) : {})
|
|
157
|
+
// dev: environments created this session, until the registry reloads with their files
|
|
158
|
+
const createdHere = new Set<string>()
|
|
159
|
+
|
|
98
160
|
/** Environments the config marks `production` or `sandbox`; the rest are judged by their name. */
|
|
99
161
|
async function environmentRisks(
|
|
100
162
|
version: number,
|
|
@@ -105,20 +167,38 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
105
167
|
const risk = (e as { risk?: string } | undefined)?.risk
|
|
106
168
|
if (risk === 'production' || risk === 'sandbox') out[name] = risk
|
|
107
169
|
}
|
|
170
|
+
// a copy is as careful as what it was copied from
|
|
171
|
+
for (const [name, b] of Object.entries(browserEnvs()))
|
|
172
|
+
if (b.from && out[b.from] && !out[name]) out[name] = out[b.from] as 'production' | 'sandbox'
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One line per environment that has a description, for the pickers' tooltips. */
|
|
177
|
+
async function environmentNotes(version: number): Promise<Record<string, string>> {
|
|
178
|
+
const c = await config(version)
|
|
179
|
+
const out: Record<string, string> = {}
|
|
180
|
+
for (const [name, e] of Object.entries(c.environments ?? {})) {
|
|
181
|
+
const note = (e as { description?: string } | undefined)?.description
|
|
182
|
+
if (note) out[name] = note
|
|
183
|
+
}
|
|
184
|
+
for (const [name, b] of Object.entries(browserEnvs()))
|
|
185
|
+
if (b.from) out[name] = `copy of ${b.from}, kept in this browser`
|
|
108
186
|
return out
|
|
109
187
|
}
|
|
110
188
|
|
|
111
189
|
async function environments(version: number): Promise<string[]> {
|
|
112
190
|
const c = await config(version)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return [...envs]
|
|
191
|
+
return environmentNames(
|
|
192
|
+
{ services: c.services ?? {}, environments: c.environments },
|
|
193
|
+
[...Object.keys(reg.environmentVars ?? {}), ...createdHere],
|
|
194
|
+
Object.keys(browserEnvs()),
|
|
195
|
+
)
|
|
119
196
|
}
|
|
120
197
|
|
|
121
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* The environment's own variables: read live from the dev server; baked in for a static build, with
|
|
200
|
+
* the ones this browser holds over them.
|
|
201
|
+
*/
|
|
122
202
|
async function environmentVars(env: string): Promise<Record<string, string>> {
|
|
123
203
|
if (reg.mode === 'dev') {
|
|
124
204
|
try {
|
|
@@ -128,7 +208,166 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
128
208
|
/* fall back to what was baked in */
|
|
129
209
|
}
|
|
130
210
|
}
|
|
131
|
-
return reg.environmentVars?.[env]
|
|
211
|
+
return { ...reg.environmentVars?.[env], ...browserEnvs()[env]?.vars }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What an address is rendered with: defaults, the environment's own settings, its file or browser copy. */
|
|
215
|
+
async function scopeOf(env: string, version: number): Promise<Record<string, unknown>> {
|
|
216
|
+
return environmentScope(await config(version), env, await environmentVars(env))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Where `service`'s calls go in `env`; undefined when it has no address there. Throws an AddressError. */
|
|
220
|
+
async function baseUrlOf(service: string, env: string, version: number) {
|
|
221
|
+
return baseUrlFor(await config(version), service, env, await scopeOf(env, version))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A profile's sign-in settings with its address fields rendered for `env`. */
|
|
225
|
+
async function oauthOf(
|
|
226
|
+
profile: string,
|
|
227
|
+
env: string,
|
|
228
|
+
version: number,
|
|
229
|
+
): Promise<OAuthSettings | undefined> {
|
|
230
|
+
const oauth = ((await config(version)).auth?.[profile] as AuthProfile | undefined)?.oauth
|
|
231
|
+
// nothing to render, so nothing to ask the dev server for
|
|
232
|
+
if (!oauth || !hasTemplate(oauth)) return oauth
|
|
233
|
+
return oauthFor(oauth, await scopeOf(env, version), profile)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Addresses for a config the caller already holds (a page reading `engine.config` once for several
|
|
238
|
+
* services): the same rendering as `baseUrlOf` / `oauthOf`, with an address that cannot be worked out
|
|
239
|
+
* left undefined. The environment's variables are fetched only when something is written as a template.
|
|
240
|
+
*/
|
|
241
|
+
async function addressesIn(cfg: SpecbookConfig, env: string) {
|
|
242
|
+
const templated =
|
|
243
|
+
Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl)) ||
|
|
244
|
+
Object.values(cfg.auth ?? {}).some((p) => hasTemplate((p as AuthProfile).oauth))
|
|
245
|
+
const scope = environmentScope(cfg, env, templated ? await environmentVars(env) : {})
|
|
246
|
+
return {
|
|
247
|
+
baseUrl(service: string): string | undefined {
|
|
248
|
+
try {
|
|
249
|
+
return baseUrlFor(cfg, service, env, scope)
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
oauth(profile: string): OAuthSettings | undefined {
|
|
255
|
+
const oauth = (cfg.auth?.[profile] as AuthProfile | undefined)?.oauth
|
|
256
|
+
try {
|
|
257
|
+
return oauth ? oauthFor(oauth, scope, profile) : undefined
|
|
258
|
+
} catch {
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The environment's variables as the settings show them, and where each service's calls go. */
|
|
266
|
+
async function environmentView(env: string, version: number): Promise<EnvironmentView> {
|
|
267
|
+
const c = await config(version)
|
|
268
|
+
const defaults = c.vars ?? {}
|
|
269
|
+
const section =
|
|
270
|
+
(c.environments?.[env] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
|
|
271
|
+
const stored = browserEnvs()[env]
|
|
272
|
+
const baked = isStaticPage() ? (reg.environmentVars?.[env] ?? {}) : await environmentVars(env)
|
|
273
|
+
const browser = stored?.vars ?? {}
|
|
274
|
+
const names = [
|
|
275
|
+
...new Set([
|
|
276
|
+
...Object.keys(defaults),
|
|
277
|
+
...Object.keys(section),
|
|
278
|
+
...Object.keys(baked),
|
|
279
|
+
...Object.keys(browser),
|
|
280
|
+
]),
|
|
281
|
+
]
|
|
282
|
+
const editable = reg.mode === 'dev' || !!opts.environmentStore
|
|
283
|
+
const rows: VariableRow[] = names.map((name) => {
|
|
284
|
+
const inherited = name in section ? section[name] : defaults[name]
|
|
285
|
+
const own = name in browser ? 'browser' : name in baked ? 'file' : undefined
|
|
286
|
+
const value = name in browser ? browser[name] : name in baked ? baked[name] : inherited
|
|
287
|
+
const fallbackSource = name in browser && name in baked ? baked[name] : inherited
|
|
288
|
+
return {
|
|
289
|
+
name,
|
|
290
|
+
value: text(value),
|
|
291
|
+
source: own ?? (name in section ? 'config' : 'default'),
|
|
292
|
+
fallback: own && fallbackSource !== undefined ? text(fallbackSource) : undefined,
|
|
293
|
+
editable,
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
const scope = environmentScope(c, env, { ...baked, ...browser })
|
|
297
|
+
const addresses: EnvironmentView['addresses'] = []
|
|
298
|
+
if (env !== 'mock')
|
|
299
|
+
for (const service of Object.keys(c.services ?? {})) {
|
|
300
|
+
try {
|
|
301
|
+
const url = baseUrlFor(c, service, env, scope)
|
|
302
|
+
addresses.push({ service, ...(url ? { url } : {}) })
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (!(e instanceof AddressError)) throw e
|
|
305
|
+
addresses.push({ service, error: e.message })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { rows, addresses, uses: addressUses(c), removable: !!stored?.from }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Whether a person can make environments here: the dev server writes a file, a static page keeps it. */
|
|
312
|
+
const canCreateEnvironment = () => reg.mode === 'dev' || !!opts.environmentStore
|
|
313
|
+
|
|
314
|
+
/** A new environment holding a copy of `from`'s own variables (never its secrets). */
|
|
315
|
+
async function createEnvironment(name: string, from: string, version: number): Promise<void> {
|
|
316
|
+
if (!ENV_NAME.test(name))
|
|
317
|
+
throw new Error('Use letters, digits, "-" and "_" in an environment name')
|
|
318
|
+
if (name === 'mock' || (await environments(version)).includes(name))
|
|
319
|
+
throw new Error(`"${name}" already exists`)
|
|
320
|
+
if (reg.mode === 'dev') {
|
|
321
|
+
const r = await fetch('/__specbook/environment', {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'content-type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({ name, from }),
|
|
325
|
+
})
|
|
326
|
+
if (!r.ok)
|
|
327
|
+
throw new Error(((await r.json()) as { error?: string }).error ?? 'could not create')
|
|
328
|
+
createdHere.add(name)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (!opts.environmentStore) throw new Error('This page has nowhere to keep a new environment')
|
|
332
|
+
const c = await config(version)
|
|
333
|
+
const section =
|
|
334
|
+
(c.environments?.[from] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
|
|
335
|
+
const own = { ...section, ...(await environmentVars(from)) }
|
|
336
|
+
opts.environmentStore.save(name, {
|
|
337
|
+
from,
|
|
338
|
+
vars: Object.fromEntries(
|
|
339
|
+
Object.entries(own)
|
|
340
|
+
.filter(([, v]) => v !== null && typeof v !== 'object')
|
|
341
|
+
.map(([k, v]) => [k, String(v)]),
|
|
342
|
+
),
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Sets (or, with `null`, removes) one of an environment's own variables. */
|
|
347
|
+
async function setVariable(env: string, name: string, value: string | null): Promise<void> {
|
|
348
|
+
if (!VAR_NAME.test(name)) throw new Error(`"${name}" is not a variable name`)
|
|
349
|
+
if (reg.mode === 'dev') {
|
|
350
|
+
const r = await fetch('/__specbook/var', {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { 'content-type': 'application/json' },
|
|
353
|
+
body: JSON.stringify({ env, name, value }),
|
|
354
|
+
})
|
|
355
|
+
if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const store = opts.environmentStore
|
|
359
|
+
if (!store) throw new Error('This page has nowhere to keep a variable')
|
|
360
|
+
const current = store.all()[env] ?? { vars: {} }
|
|
361
|
+
const vars = { ...current.vars }
|
|
362
|
+
if (value === null) delete vars[name]
|
|
363
|
+
else vars[name] = value
|
|
364
|
+
if (!current.from && Object.keys(vars).length === 0) store.remove(env)
|
|
365
|
+
else store.save(env, { ...current, vars })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Forgets an environment made in this browser. */
|
|
369
|
+
function deleteEnvironment(env: string): void {
|
|
370
|
+
opts.environmentStore?.remove(env)
|
|
132
371
|
}
|
|
133
372
|
|
|
134
373
|
/** Names of the variables the project already supplies in `env`: config, per-environment and env files. */
|
|
@@ -147,14 +386,30 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
147
386
|
* One transport per run, routing each request by its service (`routeFor`). Typed values are bound to
|
|
148
387
|
* the API's origin first; placeholders left over are filled by the dev server or reported as missing.
|
|
149
388
|
*/
|
|
150
|
-
async function transportFor(
|
|
389
|
+
async function transportFor(
|
|
390
|
+
env: string,
|
|
391
|
+
proxyBase: string,
|
|
392
|
+
version: number,
|
|
393
|
+
/** extra services by name -> base URL: a sign-in's token endpoint, which is not a configured service */
|
|
394
|
+
extra: Record<string, string> = {},
|
|
395
|
+
): Promise<Transport> {
|
|
151
396
|
if (env === 'mock') return createMockTransport({ loader: mockLoader })
|
|
152
397
|
const cfg = await config(version)
|
|
398
|
+
// the environment's variables are only fetched when an address is written as a template
|
|
399
|
+
const templated = Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl))
|
|
400
|
+
const scope = templated ? await scopeOf(env, version) : {}
|
|
153
401
|
const pageHref = globalThis.location?.href
|
|
154
402
|
const pageOrigin = globalThis.location?.origin
|
|
155
403
|
const baseOf = (service: string) => {
|
|
156
|
-
|
|
157
|
-
|
|
404
|
+
if (extra[service]) return extra[service]
|
|
405
|
+
let url: string | undefined
|
|
406
|
+
try {
|
|
407
|
+
url = baseUrlFor(cfg, service, env, scope)
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (e instanceof AddressError) throw new Error(e.message)
|
|
410
|
+
throw e
|
|
411
|
+
}
|
|
412
|
+
if (!url) throw new Error(`No base URL for ${service} in ${env}`)
|
|
158
413
|
return absoluteBase(url, pageHref)
|
|
159
414
|
}
|
|
160
415
|
const hasProxy = reg.mode !== 'static'
|
|
@@ -194,6 +449,14 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
194
449
|
return await inner(req, signal)
|
|
195
450
|
} catch (e) {
|
|
196
451
|
// a blocked cross-origin call surfaces as a bare TypeError("Failed to fetch")
|
|
452
|
+
if (
|
|
453
|
+
e instanceof TypeError &&
|
|
454
|
+
(req.service.startsWith(AUTH_SERVICE_PREFIX) ||
|
|
455
|
+
req.service.startsWith(DISCOVERY_SERVICE_PREFIX))
|
|
456
|
+
)
|
|
457
|
+
throw new Error(
|
|
458
|
+
`The browser could not reach the sign-in provider (${extra[req.service] ?? 'unknown'}). It probably does not allow calls from this page (CORS). Embedding the viewer on the API's origin does not help when the sign-in server is on another host; the browser extension can make the call. You can still paste an access token.`,
|
|
459
|
+
)
|
|
197
460
|
if (e instanceof TypeError)
|
|
198
461
|
throw new Error(
|
|
199
462
|
`The browser could not reach ${req.service} in "${env}". The API probably does not allow calls from this page (CORS). It works when the viewer runs on the API's own origin: embed it in the API's docs route ("Embed the viewer" in the README). Mock data keeps working.`,
|
|
@@ -219,6 +482,111 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
219
482
|
}
|
|
220
483
|
}
|
|
221
484
|
|
|
485
|
+
type SignInEndpoints = DiscoveredEndpoints & { scopes: Record<string, string> }
|
|
486
|
+
const discovered = new Map<string, Promise<DiscoveredEndpoints>>()
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Where a profile signs in: the endpoints its config writes out, or the ones its OpenID Connect
|
|
490
|
+
* discovery document names. The document is fetched the way any other call from the page is (through
|
|
491
|
+
* the dev server's proxy when there is one) and remembered for the life of the page.
|
|
492
|
+
*/
|
|
493
|
+
async function oauthEndpoints(i: {
|
|
494
|
+
env: string
|
|
495
|
+
version: number
|
|
496
|
+
profile: string
|
|
497
|
+
proxyBase?: string
|
|
498
|
+
signal?: AbortSignal
|
|
499
|
+
}): Promise<SignInEndpoints> {
|
|
500
|
+
if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
|
|
501
|
+
let oauth: OAuthSettings | undefined
|
|
502
|
+
try {
|
|
503
|
+
oauth = await oauthOf(i.profile, i.env, i.version)
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e instanceof AddressError) throw new Error(e.message)
|
|
506
|
+
throw e
|
|
507
|
+
}
|
|
508
|
+
if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
|
|
509
|
+
if (!isDiscovery(oauth))
|
|
510
|
+
return {
|
|
511
|
+
authorizationUrl: oauth.authorizationUrl,
|
|
512
|
+
tokenUrl: oauth.tokenUrl,
|
|
513
|
+
scopes: oauth.scopes ?? {},
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const url = new URL(oauth.openIdConnectUrl)
|
|
517
|
+
let found = discovered.get(url.href)
|
|
518
|
+
if (!found) {
|
|
519
|
+
found = (async () => {
|
|
520
|
+
const service = discoveryService(i.profile)
|
|
521
|
+
const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
|
|
522
|
+
[service]: url.origin,
|
|
523
|
+
})
|
|
524
|
+
const res = await send(
|
|
525
|
+
{
|
|
526
|
+
service,
|
|
527
|
+
opId: 'oidc-discovery',
|
|
528
|
+
method: 'GET',
|
|
529
|
+
url: url.pathname,
|
|
530
|
+
query: Object.fromEntries(url.searchParams),
|
|
531
|
+
headers: { accept: 'application/json' },
|
|
532
|
+
encoding: 'none',
|
|
533
|
+
},
|
|
534
|
+
i.signal,
|
|
535
|
+
)
|
|
536
|
+
if (res.status >= 400)
|
|
537
|
+
throw new Error(`The provider's discovery document answered ${res.status}`)
|
|
538
|
+
return parseDiscovery(res.body)
|
|
539
|
+
})()
|
|
540
|
+
discovered.set(url.href, found)
|
|
541
|
+
// a failure is not remembered: the next click tries again
|
|
542
|
+
found.catch(() => discovered.delete(url.href))
|
|
543
|
+
}
|
|
544
|
+
const endpoints = await found
|
|
545
|
+
return {
|
|
546
|
+
...endpoints,
|
|
547
|
+
scopes: oauth.scopes && Object.keys(oauth.scopes).length ? oauth.scopes : endpoints.scopes,
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* A sign-in's code exchange, sent the way any other call from this page is: through the dev server's
|
|
553
|
+
* proxy when there is one, else direct or through the extension. The token endpoint comes from the
|
|
554
|
+
* profile in the config (or its discovery document), never from the caller, so the proxy only ever
|
|
555
|
+
* forwards to the project's own.
|
|
556
|
+
*/
|
|
557
|
+
async function oauthExchange(i: {
|
|
558
|
+
env: string
|
|
559
|
+
version: number
|
|
560
|
+
profile: string
|
|
561
|
+
fields: Record<string, string>
|
|
562
|
+
proxyBase?: string
|
|
563
|
+
signal?: AbortSignal
|
|
564
|
+
}): Promise<unknown> {
|
|
565
|
+
const { tokenUrl } = await oauthEndpoints(i)
|
|
566
|
+
const token = new URL(tokenUrl)
|
|
567
|
+
const service = authService(i.profile)
|
|
568
|
+
const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
|
|
569
|
+
[service]: token.origin,
|
|
570
|
+
})
|
|
571
|
+
const res = await send(
|
|
572
|
+
{
|
|
573
|
+
service,
|
|
574
|
+
opId: 'oauth-token',
|
|
575
|
+
method: 'POST',
|
|
576
|
+
url: token.pathname,
|
|
577
|
+
query: Object.fromEntries(token.searchParams),
|
|
578
|
+
headers: { accept: 'application/json' },
|
|
579
|
+
body: i.fields,
|
|
580
|
+
encoding: 'form',
|
|
581
|
+
},
|
|
582
|
+
i.signal,
|
|
583
|
+
)
|
|
584
|
+
const body = res.body as { error?: unknown } | undefined
|
|
585
|
+
if (res.status >= 400 && typeof body?.error !== 'string')
|
|
586
|
+
throw new Error(`The token endpoint answered ${res.status}`)
|
|
587
|
+
return res.body
|
|
588
|
+
}
|
|
589
|
+
|
|
222
590
|
const norm = (p: string) => p.replace(/^\.\//, '')
|
|
223
591
|
|
|
224
592
|
/** Display order: the author's examples first, generated ones last. */
|
|
@@ -255,13 +623,9 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
255
623
|
)
|
|
256
624
|
return {
|
|
257
625
|
env: i.env,
|
|
258
|
-
vars:
|
|
259
|
-
...(c.vars ?? {}),
|
|
260
|
-
...(c.environments?.[i.env]?.vars ?? {}),
|
|
261
|
-
...(await environmentVars(i.env)),
|
|
262
|
-
},
|
|
626
|
+
vars: await scopeOf(i.env, i.version),
|
|
263
627
|
overrides: i.vars,
|
|
264
|
-
|
|
628
|
+
inputs: i.inputs,
|
|
265
629
|
transport: await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version),
|
|
266
630
|
// placeholders stay in the request: the dev server's proxy or a static page's transport fills them
|
|
267
631
|
deferSecrets: true,
|
|
@@ -270,6 +634,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
270
634
|
onEvent: i.onEvent,
|
|
271
635
|
onInput: i.onInput,
|
|
272
636
|
signal: i.signal,
|
|
637
|
+
// the project's own, else the runner's default
|
|
638
|
+
timeoutMs: c.timeoutMs,
|
|
273
639
|
example: (op: { service: string; id: string }, name: string) =>
|
|
274
640
|
loadExample(`${op.service}/${op.id}`, name, i.version),
|
|
275
641
|
readFile: (path: string, dir?: string) => readFile(path, dir ?? i.fileDir),
|
|
@@ -294,27 +660,31 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
294
660
|
return usesFaker([base, data, target.dataPatches, path, query, headers, i.vars])
|
|
295
661
|
}
|
|
296
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Makes faker ready for `locale` (a bundled module offers each locale as its own chunk, so only the ones it falls back
|
|
665
|
+
* through are fetched). Generated values need it before a run: a form that offers one asks for it when it opens.
|
|
666
|
+
*/
|
|
667
|
+
async function fakerReady(version: number, locale?: string, seed?: number): Promise<boolean> {
|
|
668
|
+
if (!reg.loadFaker) return false
|
|
669
|
+
const [mod, cfg] = await Promise.all([reg.loadFaker(version), config(version)])
|
|
670
|
+
const ready = await prepareFaker(mod, cfg.faker, locale)
|
|
671
|
+
installFaker(ready as never, cfg.faker, { locale, seed })
|
|
672
|
+
return true
|
|
673
|
+
}
|
|
674
|
+
|
|
297
675
|
async function run(
|
|
298
676
|
target: RequestDef<any, any, any> | Sequence<any, any>,
|
|
299
677
|
i: RunInput,
|
|
300
678
|
): Promise<RunResult> {
|
|
301
679
|
// `init` generators call faker, so it must be ready before any run unless we can see it isn't used.
|
|
302
|
-
if (reg.loadFaker && (await mayUseFaker(target, i)))
|
|
303
|
-
|
|
304
|
-
// a bundled module offers each locale as its own chunk; fetch only the ones this run falls back through
|
|
305
|
-
const ready = await prepareFaker(mod, cfg.faker, i.locale)
|
|
306
|
-
installFaker(ready as never, cfg.faker, { locale: i.locale, seed: i.seed })
|
|
307
|
-
}
|
|
680
|
+
if (reg.loadFaker && (await mayUseFaker(target, i)))
|
|
681
|
+
await fakerReady(i.version, i.locale, i.seed)
|
|
308
682
|
const o = await baseOptions(i)
|
|
309
683
|
if (target.kind === 'sequence') return runSequence(target, o)
|
|
310
|
-
|
|
311
|
-
i.mockStatus !== undefined && i.env === 'mock'
|
|
312
|
-
? target.with({ mock: { status: i.mockStatus } })
|
|
313
|
-
: target
|
|
314
|
-
return runRequest(def, o)
|
|
684
|
+
return runRequest(target, i.env === 'mock' ? { ...o, mockStatus: i.mockStatus } : o)
|
|
315
685
|
}
|
|
316
686
|
|
|
317
|
-
/** `base` keeps its init/capture/assert
|
|
687
|
+
/** `base` keeps its init/capture/assert and mock hook; `overrides` replace path/query/headers/data. */
|
|
318
688
|
async function operationRequest(
|
|
319
689
|
key: string,
|
|
320
690
|
version: number,
|
|
@@ -327,7 +697,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
327
697
|
const op = mod[key.split('/')[1] as string]
|
|
328
698
|
if (!op) throw new Error(`Operation module for ${key} has no export`)
|
|
329
699
|
const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
|
|
330
|
-
|
|
700
|
+
const made = op.request({ ...(base?.input ?? {}), ...defined })
|
|
701
|
+
return base?.mockHook ? withMock(made, base.mockHook) : made
|
|
331
702
|
}
|
|
332
703
|
|
|
333
704
|
async function operationMeta(key: string, version: number): Promise<OpMeta | undefined> {
|
|
@@ -400,8 +771,21 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
400
771
|
config,
|
|
401
772
|
environments,
|
|
402
773
|
environmentRisks,
|
|
774
|
+
environmentNotes,
|
|
775
|
+
environmentVars,
|
|
776
|
+
environmentView,
|
|
777
|
+
canCreateEnvironment,
|
|
778
|
+
createEnvironment,
|
|
779
|
+
deleteEnvironment,
|
|
780
|
+
setVariable,
|
|
781
|
+
baseUrlOf,
|
|
782
|
+
oauthOf,
|
|
783
|
+
addressesIn,
|
|
403
784
|
managedSecrets,
|
|
785
|
+
oauthEndpoints,
|
|
786
|
+
oauthExchange,
|
|
404
787
|
run,
|
|
788
|
+
fakerReady,
|
|
405
789
|
operationRequest,
|
|
406
790
|
operationMeta,
|
|
407
791
|
examplesOf,
|