@specbook/ui 0.1.0 → 0.2.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-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/{panel-Coi9H1nv.css → panel-C-vBZrep.css} +1 -1
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/{worker-Dw3Ps3hk.js → worker-ClGb0tE1.js} +1 -1
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +5 -4
- package/src/app.tsx +7 -2
- package/src/components/EnvBadge.tsx +4 -2
- package/src/embed/panel.tsx +2 -1
- package/src/engine.ts +143 -1
- package/src/main.tsx +9 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/CodeSample.tsx +4 -3
- package/src/panel/OAuthSignIn.tsx +163 -0
- package/src/panel/Panel.tsx +8 -4
- package/src/panel/RequestPanel.tsx +2 -0
- package/src/panel/SecretsBar.tsx +35 -2
- package/src/store.ts +2 -0
- package/src/styles/panel.css +44 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/protocol.ts +3 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +11 -2
- package/src/viewer/worker.ts +12 -7
- 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/registry-DI74Wtj8.js +0 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { isDiscovery, type OAuthSettings } from '@specbook/core'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { noteExpiry, recallClientId, redirectUri, rememberClientId, signIn } from '../oauth'
|
|
4
|
+
import { engine } from '../runtime'
|
|
5
|
+
import { secretStore } from '../secrets'
|
|
6
|
+
import { env, version } from '../store'
|
|
7
|
+
|
|
8
|
+
type Status = { kind: 'idle' } | { kind: 'busy' } | { kind: 'error'; message: string }
|
|
9
|
+
|
|
10
|
+
interface Endpoints {
|
|
11
|
+
authorizationUrl: string
|
|
12
|
+
scopes: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Sign in" for a scheme with an authorization code flow, or an OpenID Connect provider. The token lands
|
|
17
|
+
* in the same tab-scoped store, bound to the API's origin, that a pasted value would, so everything
|
|
18
|
+
* downstream is unchanged. A failure or a cancel only reports itself: the paste field next to this stays
|
|
19
|
+
* usable.
|
|
20
|
+
*/
|
|
21
|
+
export function OAuthSignIn({
|
|
22
|
+
service,
|
|
23
|
+
profile,
|
|
24
|
+
origin,
|
|
25
|
+
secret,
|
|
26
|
+
oauth,
|
|
27
|
+
onSignedIn,
|
|
28
|
+
}: {
|
|
29
|
+
service: string
|
|
30
|
+
profile: string
|
|
31
|
+
origin: string
|
|
32
|
+
/** the secret the profile's `apply` reads; the token is stored under it */
|
|
33
|
+
secret: string
|
|
34
|
+
oauth: OAuthSettings
|
|
35
|
+
onSignedIn(): void
|
|
36
|
+
}) {
|
|
37
|
+
// one provider, one remembered client id: keyed by the address that identifies the provider
|
|
38
|
+
const providerKey = isDiscovery(oauth) ? oauth.openIdConnectUrl : oauth.authorizationUrl
|
|
39
|
+
const [clientId, setClientId] = useState(
|
|
40
|
+
() => oauth.clientId ?? recallClientId(providerKey) ?? '',
|
|
41
|
+
)
|
|
42
|
+
const [endpoints, setEndpoints] = useState<Endpoints | undefined>(undefined)
|
|
43
|
+
const [picked, setPicked] = useState<string[]>([])
|
|
44
|
+
const [status, setStatus] = useState<Status>({ kind: 'idle' })
|
|
45
|
+
const abort = useRef<AbortController | undefined>(undefined)
|
|
46
|
+
const busy = status.kind === 'busy'
|
|
47
|
+
const [attempt, setAttempt] = useState(0)
|
|
48
|
+
|
|
49
|
+
// The provider's endpoints are settled before a click: the popup has to open from the click itself,
|
|
50
|
+
// and an OpenID Connect provider has to be asked where it is first.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let on = true
|
|
53
|
+
setEndpoints(undefined)
|
|
54
|
+
setStatus((s) => (s.kind === 'busy' ? s : { kind: 'idle' }))
|
|
55
|
+
engine
|
|
56
|
+
.oauthEndpoints({ env: env.value, version: version.value, profile })
|
|
57
|
+
.then((found) => {
|
|
58
|
+
if (!on) return
|
|
59
|
+
setEndpoints(found)
|
|
60
|
+
// OpenID Connect asks for `openid` alone until more is ticked; a plain OAuth scheme lists what
|
|
61
|
+
// its operations need, so those are all ticked
|
|
62
|
+
setPicked(isDiscovery(oauth) ? ['openid'] : Object.keys(found.scopes))
|
|
63
|
+
})
|
|
64
|
+
.catch((e: unknown) => {
|
|
65
|
+
if (on) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
|
|
66
|
+
})
|
|
67
|
+
return () => {
|
|
68
|
+
on = false
|
|
69
|
+
}
|
|
70
|
+
}, [profile, env.value, version.value, attempt])
|
|
71
|
+
|
|
72
|
+
const scopeNames = Object.keys(endpoints?.scopes ?? {})
|
|
73
|
+
|
|
74
|
+
// called from the click itself: the popup must open before anything is awaited
|
|
75
|
+
const start = () => {
|
|
76
|
+
if (!endpoints) return setAttempt((n) => n + 1)
|
|
77
|
+
const id = clientId.trim()
|
|
78
|
+
if (!id)
|
|
79
|
+
return setStatus({ kind: 'error', message: 'Enter the client id registered for this page.' })
|
|
80
|
+
const ctl = new AbortController()
|
|
81
|
+
abort.current = ctl
|
|
82
|
+
setStatus({ kind: 'busy' })
|
|
83
|
+
rememberClientId(providerKey, id)
|
|
84
|
+
signIn({
|
|
85
|
+
authorizationUrl: endpoints.authorizationUrl,
|
|
86
|
+
clientId: id,
|
|
87
|
+
scopes: picked,
|
|
88
|
+
signal: ctl.signal,
|
|
89
|
+
exchange: (fields, signal) =>
|
|
90
|
+
engine.oauthExchange({ env: env.value, version: version.value, profile, fields, signal }),
|
|
91
|
+
})
|
|
92
|
+
.then((token) => {
|
|
93
|
+
secretStore.set(origin, secret, token.accessToken)
|
|
94
|
+
noteExpiry(origin, secret, token.expiresIn)
|
|
95
|
+
setStatus({ kind: 'idle' })
|
|
96
|
+
onSignedIn()
|
|
97
|
+
})
|
|
98
|
+
.catch((e: unknown) =>
|
|
99
|
+
setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) }),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const failedLookup = !endpoints && status.kind === 'error'
|
|
104
|
+
// the idle line (the address to register) is the only one a phone can do without
|
|
105
|
+
const hint = !busy && status.kind !== 'error' && !!endpoints
|
|
106
|
+
return (
|
|
107
|
+
<span class="needs-var oauth-signin">
|
|
108
|
+
<input
|
|
109
|
+
type="text"
|
|
110
|
+
autocomplete="off"
|
|
111
|
+
aria-label={`Client id for ${service}`}
|
|
112
|
+
placeholder="client id"
|
|
113
|
+
value={clientId}
|
|
114
|
+
disabled={busy}
|
|
115
|
+
onInput={(e) => setClientId((e.target as HTMLInputElement).value)}
|
|
116
|
+
/>
|
|
117
|
+
{busy ? (
|
|
118
|
+
<button type="button" class="link" onClick={() => abort.current?.abort()}>
|
|
119
|
+
Cancel
|
|
120
|
+
</button>
|
|
121
|
+
) : (
|
|
122
|
+
<button type="button" class="btn" disabled={!endpoints && !failedLookup} onClick={start}>
|
|
123
|
+
{failedLookup ? 'Try again' : 'Sign in'}
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
{scopeNames.length > 0 && (
|
|
127
|
+
<details>
|
|
128
|
+
<summary class="muted small">
|
|
129
|
+
Scopes ({picked.length} of {scopeNames.length})
|
|
130
|
+
</summary>
|
|
131
|
+
{scopeNames.map((s) => (
|
|
132
|
+
<label class="small" title={endpoints?.scopes[s] || undefined}>
|
|
133
|
+
<input
|
|
134
|
+
type="checkbox"
|
|
135
|
+
checked={picked.includes(s)}
|
|
136
|
+
disabled={busy}
|
|
137
|
+
onChange={(e) =>
|
|
138
|
+
setPicked((p) =>
|
|
139
|
+
(e.target as HTMLInputElement).checked ? [...p, s] : p.filter((x) => x !== s),
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
/>{' '}
|
|
143
|
+
<code>{s}</code>
|
|
144
|
+
</label>
|
|
145
|
+
))}
|
|
146
|
+
</details>
|
|
147
|
+
)}
|
|
148
|
+
<span
|
|
149
|
+
class={`muted small oauth-status ${hint ? 'oauth-hint' : ''}`}
|
|
150
|
+
role="status"
|
|
151
|
+
aria-live="polite"
|
|
152
|
+
>
|
|
153
|
+
{busy
|
|
154
|
+
? 'Waiting for the sign-in window…'
|
|
155
|
+
: status.kind === 'error'
|
|
156
|
+
? status.message
|
|
157
|
+
: !endpoints
|
|
158
|
+
? 'Looking up the provider…'
|
|
159
|
+
: `Register this redirect URI with the provider: ${redirectUri()}`}
|
|
160
|
+
</span>
|
|
161
|
+
</span>
|
|
162
|
+
)
|
|
163
|
+
}
|
package/src/panel/Panel.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact'
|
|
2
|
-
import { useEffect, useRef } from 'preact/hooks'
|
|
3
|
-
import { env, environments } from '../store'
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'
|
|
3
|
+
import { env, environmentNote, environments } from '../store'
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
title: string
|
|
@@ -99,7 +99,9 @@ export function Panel({
|
|
|
99
99
|
}
|
|
100
100
|
}, [])
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
// A layout effect, so the keys work from the moment the panel is on screen: an effect waits for the next
|
|
103
|
+
// frame, and a key pressed (or sent by a test, or a host page) before it was ignored
|
|
104
|
+
useLayoutEffect(() => {
|
|
103
105
|
const key = (e: KeyboardEvent) => {
|
|
104
106
|
if (e.key === 'Escape') onClose()
|
|
105
107
|
if (e.key !== 'Tab' || !modal || !aside.current) return
|
|
@@ -153,7 +155,9 @@ export function Panel({
|
|
|
153
155
|
onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
|
|
154
156
|
>
|
|
155
157
|
{environments.value.map((e) => (
|
|
156
|
-
<option value={e}
|
|
158
|
+
<option value={e} title={environmentNote.value[e]}>
|
|
159
|
+
{e}
|
|
160
|
+
</option>
|
|
157
161
|
))}
|
|
158
162
|
</select>
|
|
159
163
|
</label>
|
|
@@ -243,6 +243,8 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
|
|
|
243
243
|
setError(e instanceof Error ? e.message : String(e))
|
|
244
244
|
} finally {
|
|
245
245
|
setRunning(false)
|
|
246
|
+
// what you sent a request to see is the response, so show it (the tab stays one click away)
|
|
247
|
+
setTab('response')
|
|
246
248
|
}
|
|
247
249
|
}
|
|
248
250
|
|
package/src/panel/SecretsBar.tsx
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
+
import type { AuthProfile, OAuthSettings } from '@specbook/core'
|
|
1
2
|
import { useEffect, useState } from 'preact/hooks'
|
|
3
|
+
import { expiryOf, noteExpiry } from '../oauth'
|
|
2
4
|
import { absoluteBase } from '../routing'
|
|
3
5
|
import { engine } from '../runtime'
|
|
4
6
|
import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
|
|
5
7
|
import { env, version } from '../store'
|
|
8
|
+
import { OAuthSignIn } from './OAuthSignIn'
|
|
6
9
|
|
|
7
10
|
interface Need {
|
|
8
11
|
service: string
|
|
9
12
|
origin: string
|
|
10
13
|
title: string
|
|
11
14
|
names: string[]
|
|
15
|
+
/** the config's name for the profile (the key a sign-in's token exchange is addressed by) */
|
|
16
|
+
profile: string
|
|
17
|
+
oauth?: OAuthSettings
|
|
12
18
|
}
|
|
13
19
|
|
|
20
|
+
const minutesLeft = (at: number | undefined) =>
|
|
21
|
+
at === undefined ? undefined : Math.max(0, Math.round((at - Date.now()) / 60_000))
|
|
22
|
+
|
|
14
23
|
const SOURCE: Record<string, string> = {
|
|
15
24
|
session: 'this session',
|
|
16
25
|
process: 'environment variable',
|
|
@@ -67,7 +76,15 @@ export function SecretsBar({
|
|
|
67
76
|
),
|
|
68
77
|
),
|
|
69
78
|
]
|
|
70
|
-
if (names.length)
|
|
79
|
+
if (names.length)
|
|
80
|
+
out.push({
|
|
81
|
+
service,
|
|
82
|
+
origin,
|
|
83
|
+
title: profile.title ?? s.auth ?? '',
|
|
84
|
+
names,
|
|
85
|
+
profile: s.auth as string,
|
|
86
|
+
oauth: (profile as AuthProfile).oauth,
|
|
87
|
+
})
|
|
71
88
|
}
|
|
72
89
|
setNeeds(out)
|
|
73
90
|
})
|
|
@@ -109,12 +126,18 @@ export function SecretsBar({
|
|
|
109
126
|
<code>{name}</code>
|
|
110
127
|
{has ? (
|
|
111
128
|
<>
|
|
112
|
-
<span class="muted"
|
|
129
|
+
<span class="muted">
|
|
130
|
+
✓ set
|
|
131
|
+
{n.oauth && minutesLeft(expiryOf(n.origin, name)) !== undefined
|
|
132
|
+
? ` · expires in ${minutesLeft(expiryOf(n.origin, name))} min`
|
|
133
|
+
: ''}
|
|
134
|
+
</span>
|
|
113
135
|
<button
|
|
114
136
|
type="button"
|
|
115
137
|
class="link"
|
|
116
138
|
onClick={() => {
|
|
117
139
|
secretStore.remove(n.origin, name)
|
|
140
|
+
noteExpiry(n.origin, name, undefined)
|
|
118
141
|
bump((x) => x + 1)
|
|
119
142
|
}}
|
|
120
143
|
>
|
|
@@ -124,6 +147,16 @@ export function SecretsBar({
|
|
|
124
147
|
) : (
|
|
125
148
|
<>
|
|
126
149
|
{from && <span class="muted">from {SOURCE[from] ?? from}</span>}
|
|
150
|
+
{n.oauth && (
|
|
151
|
+
<OAuthSignIn
|
|
152
|
+
service={n.service}
|
|
153
|
+
profile={n.profile}
|
|
154
|
+
origin={n.origin}
|
|
155
|
+
secret={name}
|
|
156
|
+
oauth={n.oauth}
|
|
157
|
+
onSignedIn={() => bump((x) => x + 1)}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
127
160
|
<input
|
|
128
161
|
type="password"
|
|
129
162
|
autocomplete="off"
|
package/src/store.ts
CHANGED
|
@@ -6,5 +6,7 @@ export const env = signal('mock')
|
|
|
6
6
|
export const environments = signal<string[]>(['mock'])
|
|
7
7
|
/** Environments the config marks `production` or `sandbox`; others are judged by their name. */
|
|
8
8
|
export const environmentRisk = signal<Record<string, 'production' | 'sandbox'>>({})
|
|
9
|
+
/** What the config says each environment is, for the tooltip of its entry in a picker. */
|
|
10
|
+
export const environmentNote = signal<Record<string, string>>({})
|
|
9
11
|
export const locale = signal<string | undefined>(undefined)
|
|
10
12
|
export const seed = signal<number | undefined>(undefined)
|
package/src/styles/panel.css
CHANGED
|
@@ -715,13 +715,53 @@
|
|
|
715
715
|
}
|
|
716
716
|
.needs-var {
|
|
717
717
|
display: inline-flex;
|
|
718
|
+
flex-wrap: wrap;
|
|
718
719
|
align-items: center;
|
|
719
720
|
gap: 6px;
|
|
721
|
+
min-width: 0;
|
|
722
|
+
max-width: 100%;
|
|
720
723
|
}
|
|
721
724
|
.needs-var input {
|
|
722
725
|
width: 130px;
|
|
723
726
|
min-width: 0;
|
|
724
727
|
}
|
|
728
|
+
/* the pasted value looks like the client id field beside it */
|
|
729
|
+
.needs-var input[type="password"] {
|
|
730
|
+
padding: 5px 8px;
|
|
731
|
+
border: 1px solid var(--sb-border);
|
|
732
|
+
border-radius: var(--sb-radius-control);
|
|
733
|
+
background: var(--sb-surface);
|
|
734
|
+
}
|
|
735
|
+
/* Sign in: its own row inside the credential, so the client id, the button, the scopes and the status
|
|
736
|
+
wrap under each other on a narrow screen instead of running off the edge */
|
|
737
|
+
.oauth-signin {
|
|
738
|
+
flex: 1 1 100%;
|
|
739
|
+
}
|
|
740
|
+
.oauth-signin input[type="text"] {
|
|
741
|
+
flex: 1 1 140px;
|
|
742
|
+
width: auto;
|
|
743
|
+
max-width: 360px;
|
|
744
|
+
}
|
|
745
|
+
.oauth-signin details {
|
|
746
|
+
flex: 1 1 100%;
|
|
747
|
+
min-width: 0;
|
|
748
|
+
}
|
|
749
|
+
.oauth-signin input[type="checkbox"] {
|
|
750
|
+
flex: 0 0 auto;
|
|
751
|
+
width: auto;
|
|
752
|
+
}
|
|
753
|
+
.oauth-signin details label {
|
|
754
|
+
display: flex;
|
|
755
|
+
align-items: center;
|
|
756
|
+
gap: 6px;
|
|
757
|
+
min-height: 28px;
|
|
758
|
+
overflow-wrap: anywhere;
|
|
759
|
+
}
|
|
760
|
+
.oauth-status {
|
|
761
|
+
flex: 1 1 100%;
|
|
762
|
+
min-width: 0;
|
|
763
|
+
overflow-wrap: anywhere;
|
|
764
|
+
}
|
|
725
765
|
.rq-meta {
|
|
726
766
|
display: flex;
|
|
727
767
|
align-items: center;
|
|
@@ -1787,8 +1827,10 @@
|
|
|
1787
1827
|
flex-wrap: wrap;
|
|
1788
1828
|
padding-inline: var(--sb-space-3);
|
|
1789
1829
|
}
|
|
1790
|
-
/* the credentials note is in the chip's tooltip; not worth a line of a phone screen
|
|
1791
|
-
|
|
1830
|
+
/* the credentials note is in the chip's tooltip; not worth a line of a phone screen. The redirect
|
|
1831
|
+
address to register is a desktop job too; a problem or a wait is still shown */
|
|
1832
|
+
.secrets-note,
|
|
1833
|
+
.oauth-hint {
|
|
1792
1834
|
display: none;
|
|
1793
1835
|
}
|
|
1794
1836
|
.secrets-folded,
|
package/src/viewer/embed.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* The script takes the page over (it owns the document, as `/docs` does). `spec` may be relative.
|
|
10
10
|
* `baseUrl` gives the API address when the document has none, `target` names another container.
|
|
11
11
|
*/
|
|
12
|
+
import { handleOAuthCallback } from '../oauth-callback'
|
|
12
13
|
import { SpecWorker } from './client'
|
|
13
14
|
import { mountOpened } from './mount'
|
|
14
15
|
|
|
@@ -46,6 +47,10 @@ const say = (el: HTMLElement, text: string, error = false) => {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
async function start() {
|
|
50
|
+
// A sign-in's popup comes back to this page, so the viewer is what has to hand the code on. The address
|
|
51
|
+
// is left alone unless a viewer tab claims it: the page is the host's, and its `?code=&state=` may be
|
|
52
|
+
// the host application's own sign-in.
|
|
53
|
+
if (await handleOAuthCallback({ stripWhenUnclaimed: false })) return
|
|
49
54
|
const cfg = readConfig()
|
|
50
55
|
const target = container(cfg.target)
|
|
51
56
|
if (!cfg.spec)
|
package/src/viewer/main.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render } from 'preact'
|
|
2
2
|
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { handleOAuthCallback } from '../oauth-callback'
|
|
3
4
|
import { applyTheme } from '../theme'
|
|
4
5
|
import { type OpenFailure, SpecWorker } from './client'
|
|
5
6
|
import { mountOpened } from './mount'
|
|
@@ -282,4 +283,7 @@ function BasePrompt({
|
|
|
282
283
|
)
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
|
|
286
|
+
// a sign-in's return leg lands on this page too: hand it to the tab that started it, draw nothing here
|
|
287
|
+
void handleOAuthCallback().then((handled) => {
|
|
288
|
+
if (!handled) render(<Picker initial={{ kind: 'idle' }} />, root)
|
|
289
|
+
})
|
package/src/viewer/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServerEntry } from '@specbook/core'
|
|
1
2
|
import type { AuthProfileLike } from './registry-data'
|
|
2
3
|
|
|
3
4
|
/** Where a spec comes from. */
|
|
@@ -26,6 +27,8 @@ export interface OpenResult {
|
|
|
26
27
|
defaultProfile?: string
|
|
27
28
|
/** absolute API address, from `servers` (resolved against the spec URL) or `host` */
|
|
28
29
|
baseUrl?: string
|
|
30
|
+
/** every usable server the document lists, as environments; the first is `live` */
|
|
31
|
+
servers: ServerEntry[]
|
|
29
32
|
/** a relative `servers` entry the person has to give a base URL for */
|
|
30
33
|
needsBaseUrl: boolean
|
|
31
34
|
warnings: string[]
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { OAuthSettings } from '@specbook/core'
|
|
2
|
+
|
|
1
3
|
export interface AuthProfileLike {
|
|
2
4
|
title: string
|
|
3
5
|
apply: { headers?: Record<string, string>; query?: Record<string, string> }
|
|
6
|
+
/** the scheme's authorization code flow, when it has one the page can sign in to */
|
|
7
|
+
oauth?: OAuthSettings
|
|
4
8
|
}
|
package/src/viewer/registry.ts
CHANGED
|
@@ -134,19 +134,28 @@ export const loadFaker = () =>
|
|
|
134
134
|
},
|
|
135
135
|
}))
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
// `live` is the document's first server, or the address the person typed for it; the rest keep their own names
|
|
138
|
+
const live = v.baseUrl ?? open.baseUrl
|
|
139
|
+
const baseUrl: Record<string, string> = {
|
|
140
|
+
...(live ? { live } : {}),
|
|
141
|
+
...Object.fromEntries(open.servers.filter((s) => s.name !== 'live').map((s) => [s.name, s.url])),
|
|
142
|
+
}
|
|
143
|
+
const described = Object.fromEntries(
|
|
144
|
+
open.servers.filter((s) => s.description).map((s) => [s.name, { description: s.description }]),
|
|
145
|
+
)
|
|
138
146
|
export const loadConfig = () =>
|
|
139
147
|
Promise.resolve({
|
|
140
148
|
default: {
|
|
141
149
|
project: service,
|
|
142
150
|
secrets: open.secrets,
|
|
143
151
|
auth: open.auth,
|
|
152
|
+
...(Object.keys(described).length ? { environments: described } : {}),
|
|
144
153
|
// one locale, so the header offers no locale choice
|
|
145
154
|
faker: { default: 'en', locales: { en: ['en', 'base'] } },
|
|
146
155
|
services: {
|
|
147
156
|
[service]: {
|
|
148
157
|
spec: '',
|
|
149
|
-
baseUrl
|
|
158
|
+
baseUrl,
|
|
150
159
|
...(open.defaultProfile ? { auth: open.defaultProfile } : {}),
|
|
151
160
|
},
|
|
152
161
|
},
|
package/src/viewer/worker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference lib="webworker" />
|
|
2
2
|
import { generateData, type OperationMeta, openSpec } from '@specbook/codegen/browser'
|
|
3
|
-
import { authFromSpec,
|
|
3
|
+
import { authFromSpec, MAX_SERVERS, openApiServers } from '@specbook/core'
|
|
4
4
|
import { bundleJson, type Parts, sortFiles } from './bundles'
|
|
5
5
|
import type { OpenResult, Request, Response, Source } from './protocol'
|
|
6
6
|
|
|
@@ -112,11 +112,10 @@ async function open(id: number, source: Source, signal: AbortSignal): Promise<Op
|
|
|
112
112
|
(o.examples as { name: string }[]).map((e) => e.name),
|
|
113
113
|
]),
|
|
114
114
|
)
|
|
115
|
-
const { auth, secrets, defaultProfile } = authFromSpec(doc)
|
|
116
115
|
const sourceUrl = source.kind === 'url' ? source.url : undefined
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
116
|
+
const { auth, secrets, defaultProfile } = authFromSpec(doc, sourceUrl)
|
|
117
|
+
const { servers, needsBaseUrl, omitted } = openApiServers(doc, sourceUrl ?? name ?? '')
|
|
118
|
+
const baseUrl = servers.find((s) => s.name === 'live')?.url
|
|
120
119
|
return {
|
|
121
120
|
service,
|
|
122
121
|
title,
|
|
@@ -127,8 +126,14 @@ async function open(id: number, source: Source, signal: AbortSignal): Promise<Op
|
|
|
127
126
|
secrets,
|
|
128
127
|
defaultProfile,
|
|
129
128
|
baseUrl,
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
servers,
|
|
130
|
+
needsBaseUrl,
|
|
131
|
+
warnings: omitted
|
|
132
|
+
? [
|
|
133
|
+
...warnings,
|
|
134
|
+
`This document lists ${servers.length + omitted} servers. The first ${MAX_SERVERS} are offered as environments.`,
|
|
135
|
+
]
|
|
136
|
+
: warnings,
|
|
132
137
|
timings: {
|
|
133
138
|
...(fetched !== undefined ? { fetch: Math.round(fetched) } : {}),
|
|
134
139
|
parse: Math.round(parse),
|