@specbook/ui 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -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-C-vBZrep.css +1 -0
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +61 -3
- package/src/app.tsx +368 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +48 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +115 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +574 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +24 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +238 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OAuthSignIn.tsx +163 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +197 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +627 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +189 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +12 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1939 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +83 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +289 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +54 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +8 -0
- package/src/viewer/registry.ts +163 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +176 -0
- package/src/virtual.d.ts +104 -0
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorizeUrl,
|
|
3
|
+
createPkce,
|
|
4
|
+
newState,
|
|
5
|
+
OAuthError,
|
|
6
|
+
parseCallback,
|
|
7
|
+
parseTokenResponse,
|
|
8
|
+
tokenRequestFields,
|
|
9
|
+
} from '@specbook/core'
|
|
10
|
+
import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Page side of "Sign in": opens the provider in a popup, waits for the return leg
|
|
14
|
+
* (`oauth-callback.ts`), exchanges the code and hands back the access token. The verifier and the `state`
|
|
15
|
+
* live in this closure only: not in the URL, not in storage, not in a log.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The address a provider sends the person back to, and the one to register with it.
|
|
20
|
+
*
|
|
21
|
+
* The hosted site declares a stable, unversioned callback page (a `<meta>` its assembly adds to the viewer's
|
|
22
|
+
* `index.html`), so what people register does not change with the viewer's channel (`/v1/`, `/v2/`).
|
|
23
|
+
* Anywhere else (a self-hosted copy, an embed on a host page, the dev server) it is the page's own address.
|
|
24
|
+
*/
|
|
25
|
+
export const redirectUri = (): string => {
|
|
26
|
+
const declared = document
|
|
27
|
+
.querySelector<HTMLMetaElement>('meta[name="specbook-oauth-callback"]')
|
|
28
|
+
?.content.trim()
|
|
29
|
+
if (declared) {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(declared, location.origin).href
|
|
32
|
+
} catch {
|
|
33
|
+
/* not an address: fall back to the page's own */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return `${location.origin}${location.pathname}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SignInInput {
|
|
40
|
+
authorizationUrl: string
|
|
41
|
+
clientId: string
|
|
42
|
+
scopes: string[]
|
|
43
|
+
/** posts the form to the token endpoint and returns the parsed response body */
|
|
44
|
+
exchange(fields: Record<string, string>, signal?: AbortSignal): Promise<unknown>
|
|
45
|
+
signal?: AbortSignal
|
|
46
|
+
/** how long to wait for the person to finish (default five minutes) */
|
|
47
|
+
timeoutMs?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Call this straight from a click: the popup has to open before anything is awaited or the browser
|
|
52
|
+
* blocks it.
|
|
53
|
+
*/
|
|
54
|
+
export async function signIn(
|
|
55
|
+
i: SignInInput,
|
|
56
|
+
): Promise<{ accessToken: string; expiresIn?: number; scope?: string }> {
|
|
57
|
+
// opened blank and pointed at the provider once the challenge exists, which keeps Safari's
|
|
58
|
+
// user-activation rule happy
|
|
59
|
+
const popup = window.open('', OAUTH_CHANNEL, 'popup,width=520,height=720')
|
|
60
|
+
if (!popup)
|
|
61
|
+
throw new OAuthError(
|
|
62
|
+
'The browser blocked the sign-in window. Allow pop-ups for this page and try again.',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const channel = new BroadcastChannel(OAUTH_CHANNEL)
|
|
66
|
+
let onWindowMessage: ((e: MessageEvent<OAuthMessage>) => void) | undefined
|
|
67
|
+
try {
|
|
68
|
+
const { verifier, challenge } = await createPkce()
|
|
69
|
+
const state = newState()
|
|
70
|
+
const redirect = redirectUri()
|
|
71
|
+
|
|
72
|
+
const returned = new Promise<OAuthMessage & { type: 'callback' }>((resolve, reject) => {
|
|
73
|
+
const timer = setTimeout(
|
|
74
|
+
() => reject(new OAuthError('Sign-in timed out. Try again.')),
|
|
75
|
+
i.timeoutMs ?? 5 * 60_000,
|
|
76
|
+
)
|
|
77
|
+
const stop = () => clearTimeout(timer)
|
|
78
|
+
i.signal?.addEventListener('abort', () => {
|
|
79
|
+
stop()
|
|
80
|
+
reject(new OAuthError('Sign-in was cancelled.'))
|
|
81
|
+
})
|
|
82
|
+
// the return arrives over the channel or from the popup as its opener; both carry the same message,
|
|
83
|
+
// and the one that arrives second is a repeat of the first
|
|
84
|
+
const take = (m: OAuthMessage | undefined, ack: (a: OAuthMessage) => void) => {
|
|
85
|
+
if (m?.type !== 'callback' || m.state !== state) return
|
|
86
|
+
stop()
|
|
87
|
+
ack({ type: 'ack', state })
|
|
88
|
+
resolve(m)
|
|
89
|
+
}
|
|
90
|
+
channel.onmessage = (e: MessageEvent<OAuthMessage>) =>
|
|
91
|
+
take(e.data, (a) => channel.postMessage(a))
|
|
92
|
+
onWindowMessage = (e: MessageEvent<OAuthMessage>) => {
|
|
93
|
+
// only this origin's own callback page: another site cannot hand us a code
|
|
94
|
+
if (e.origin !== location.origin) return
|
|
95
|
+
take(e.data, (a) => (e.source as Window | null)?.postMessage(a, location.origin))
|
|
96
|
+
}
|
|
97
|
+
addEventListener('message', onWindowMessage)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
popup.location.href = authorizeUrl({
|
|
101
|
+
authorizationUrl: i.authorizationUrl,
|
|
102
|
+
clientId: i.clientId,
|
|
103
|
+
redirectUri: redirect,
|
|
104
|
+
scopes: i.scopes,
|
|
105
|
+
state,
|
|
106
|
+
challenge,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const back = await returned
|
|
110
|
+
const { code } = parseCallback(
|
|
111
|
+
{
|
|
112
|
+
state: back.state,
|
|
113
|
+
...(back.code ? { code: back.code } : {}),
|
|
114
|
+
...(back.error ? { error: back.error } : {}),
|
|
115
|
+
...(back.description ? { error_description: back.description } : {}),
|
|
116
|
+
},
|
|
117
|
+
state,
|
|
118
|
+
)
|
|
119
|
+
const body = await i.exchange(
|
|
120
|
+
tokenRequestFields({ code, verifier, clientId: i.clientId, redirectUri: redirect }),
|
|
121
|
+
i.signal,
|
|
122
|
+
)
|
|
123
|
+
return parseTokenResponse(body)
|
|
124
|
+
} finally {
|
|
125
|
+
channel.close()
|
|
126
|
+
if (onWindowMessage) removeEventListener('message', onWindowMessage)
|
|
127
|
+
try {
|
|
128
|
+
popup.close()
|
|
129
|
+
} catch {
|
|
130
|
+
/* a severed popup cannot be closed from here; the callback page closes itself */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- small per-browser conveniences: a client id is public, and an expiry note is not a secret ----
|
|
136
|
+
|
|
137
|
+
const CLIENTS = 'specbook.oauth.clients.v1'
|
|
138
|
+
const EXPIRY = 'specbook.oauth.expiry.v1'
|
|
139
|
+
|
|
140
|
+
const readJson = (store: Storage, key: string): Record<string, any> => {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(store.getItem(key) ?? '{}')
|
|
143
|
+
} catch {
|
|
144
|
+
return {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const writeJson = (store: Storage, key: string, value: unknown) => {
|
|
148
|
+
try {
|
|
149
|
+
store.setItem(key, JSON.stringify(value))
|
|
150
|
+
} catch {
|
|
151
|
+
/* blocked or full: the page still works without it */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const recallClientId = (authorizationUrl: string): string | undefined => {
|
|
156
|
+
try {
|
|
157
|
+
return readJson(localStorage, CLIENTS)[authorizationUrl]
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
|
|
164
|
+
try {
|
|
165
|
+
writeJson(localStorage, CLIENTS, {
|
|
166
|
+
...readJson(localStorage, CLIENTS),
|
|
167
|
+
[authorizationUrl]: clientId,
|
|
168
|
+
})
|
|
169
|
+
} catch {
|
|
170
|
+
/* no storage */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
|
|
175
|
+
|
|
176
|
+
/** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
|
|
177
|
+
export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
|
|
178
|
+
try {
|
|
179
|
+
const t = readJson(sessionStorage, EXPIRY)
|
|
180
|
+
if (expiresIn === undefined) delete t[expiryKey(origin, name)]
|
|
181
|
+
else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
|
|
182
|
+
writeJson(sessionStorage, EXPIRY, t)
|
|
183
|
+
} catch {
|
|
184
|
+
/* no storage */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const expiryOf = (origin: string, name: string): number | undefined => {
|
|
189
|
+
try {
|
|
190
|
+
return readJson(sessionStorage, EXPIRY)[expiryKey(origin, name)]
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { type Encoding, fromFormRows, toFormRows } from '@specbook/core'
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { type FieldDoc as Doc, fieldDoc, keyPath, withNote } from '../fielddoc'
|
|
4
|
+
import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
|
|
5
|
+
import { BodyEditor } from './BodyEditor'
|
|
6
|
+
import { jsonCompletions } from './completions'
|
|
7
|
+
import { type Draft, type FieldRow, gridMode } from './draft'
|
|
8
|
+
import { FieldDoc } from './FieldDoc'
|
|
9
|
+
import { type Graphql, joinGraphql, splitGraphql } from './graphql'
|
|
10
|
+
import { ParamGrid } from './ParamGrid'
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
encoding: Encoding
|
|
14
|
+
draft: Draft
|
|
15
|
+
onChange: (d: Draft) => void
|
|
16
|
+
/** request schema (`{ path, query, headers, data }`) and shared definitions */
|
|
17
|
+
schema?: JsonSchema
|
|
18
|
+
defs?: Defs
|
|
19
|
+
/** dotted body paths that are uploads */
|
|
20
|
+
files?: string[]
|
|
21
|
+
/** the project's own notes for body fields, by `data.<path>` */
|
|
22
|
+
notes?: Map<string, string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keeps the typed texts locally, so invalid variables JSON isn't rewritten while the user is typing. */
|
|
26
|
+
function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) => void }) {
|
|
27
|
+
const [g, setG] = useState(() => splitGraphql(draft.body) as Graphql)
|
|
28
|
+
const emitted = useRef(draft.body)
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (draft.body === emitted.current) return
|
|
31
|
+
const next = splitGraphql(draft.body)
|
|
32
|
+
if (next) setG(next)
|
|
33
|
+
emitted.current = draft.body
|
|
34
|
+
}, [draft.body])
|
|
35
|
+
|
|
36
|
+
const set = (part: Partial<Graphql>) => {
|
|
37
|
+
const next = { ...g, ...part }
|
|
38
|
+
setG(next)
|
|
39
|
+
emitted.current = joinGraphql(next)
|
|
40
|
+
onChange({ ...draft, body: emitted.current })
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
<section class="body" aria-label="Body">
|
|
44
|
+
<div class="section-head">
|
|
45
|
+
<h3>Query</h3>
|
|
46
|
+
<span class="chip">graphql</span>
|
|
47
|
+
</div>
|
|
48
|
+
<BodyEditor
|
|
49
|
+
label="GraphQL query"
|
|
50
|
+
language="graphql"
|
|
51
|
+
value={g.query}
|
|
52
|
+
onChange={(query) => set({ query })}
|
|
53
|
+
/>
|
|
54
|
+
<div class="section-head">
|
|
55
|
+
<h3>Variables</h3>
|
|
56
|
+
<span class="chip">json</span>
|
|
57
|
+
</div>
|
|
58
|
+
<BodyEditor
|
|
59
|
+
label="GraphQL variables"
|
|
60
|
+
language="json"
|
|
61
|
+
value={g.variables}
|
|
62
|
+
onChange={(variables) => set({ variables })}
|
|
63
|
+
/>
|
|
64
|
+
</section>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const language = (e: Encoding) => (e === 'graphql' ? 'graphql' : e === 'text' ? 'text' : 'json')
|
|
69
|
+
|
|
70
|
+
export function Body({ encoding, draft, onChange, schema, defs, files = [], notes }: Props) {
|
|
71
|
+
const [line, setLine] = useState(0)
|
|
72
|
+
const paths = useMemo(() => linePaths(draft.body), [draft.body])
|
|
73
|
+
const documented = encoding === 'json' || encoding === 'form'
|
|
74
|
+
const doc: Doc | undefined = useMemo(() => {
|
|
75
|
+
const p = paths[line]
|
|
76
|
+
return p && documented
|
|
77
|
+
? withNote(fieldDoc(schema, defs, p, ['data']), notes, 'data', pathString(p))
|
|
78
|
+
: undefined
|
|
79
|
+
}, [paths, line, schema, defs, documented, notes])
|
|
80
|
+
const complete = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
documented && schema
|
|
83
|
+
? (text: string, pos: number) => jsonCompletions(schema, defs, ['data'], text, pos)
|
|
84
|
+
: undefined,
|
|
85
|
+
[schema, defs, documented],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/** Form bodies convert between the nested JSON and the bracket-key rows when the view is switched. */
|
|
89
|
+
const switchView = (view: 'json' | 'grid') => {
|
|
90
|
+
if (view === (draft.formView ?? 'grid')) return
|
|
91
|
+
if (view === 'grid') {
|
|
92
|
+
try {
|
|
93
|
+
onChange({ ...draft, formView: 'grid', fields: toFormRows(JSON.parse(draft.body || '{}')) })
|
|
94
|
+
} catch {
|
|
95
|
+
onChange({ ...draft, formView: 'grid' })
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
onChange({
|
|
99
|
+
...draft,
|
|
100
|
+
formView: 'json',
|
|
101
|
+
body: JSON.stringify(fromFormRows(draft.fields.filter((r) => !r.file)), null, 2),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const viewToggle =
|
|
106
|
+
encoding === 'form' ? (
|
|
107
|
+
<fieldset class="seg" aria-label="Body view">
|
|
108
|
+
{(['json', 'grid'] as const).map((v) => (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
class={`seg-btn ${(draft.formView ?? 'grid') === v ? 'on' : ''}`}
|
|
112
|
+
aria-pressed={(draft.formView ?? 'grid') === v}
|
|
113
|
+
onClick={() => switchView(v)}
|
|
114
|
+
>
|
|
115
|
+
{v === 'json' ? 'JSON' : 'Grid'}
|
|
116
|
+
</button>
|
|
117
|
+
))}
|
|
118
|
+
</fieldset>
|
|
119
|
+
) : null
|
|
120
|
+
|
|
121
|
+
if (gridMode(draft, encoding) || encoding === 'binary') {
|
|
122
|
+
const isFile = (k: string) => encoding === 'binary' || files.includes(k.replace(/\[\d+\]/g, ''))
|
|
123
|
+
const set = (fields: FieldRow[]) => onChange({ ...draft, fields })
|
|
124
|
+
const fileKeys = (draft.fields.length ? draft.fields.map((r) => r.key) : []).filter(isFile)
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
{viewToggle && (
|
|
128
|
+
<div class="section-head">
|
|
129
|
+
<h3>Body</h3>
|
|
130
|
+
<span class="chip">{encoding}</span>
|
|
131
|
+
<span class="spacer" />
|
|
132
|
+
{viewToggle}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<ParamGrid
|
|
136
|
+
title={encoding === 'binary' ? 'File' : 'Body'}
|
|
137
|
+
bare={!!viewToggle}
|
|
138
|
+
rows={draft.fields}
|
|
139
|
+
onChange={set}
|
|
140
|
+
documented={[...new Set(fileKeys)].map((name) => ({ name, file: true }))}
|
|
141
|
+
docFor={(k) => withNote(fieldDoc(schema, defs, keyPath(k), ['data']), notes, 'data', k)}
|
|
142
|
+
/>
|
|
143
|
+
</>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const format = () => {
|
|
148
|
+
try {
|
|
149
|
+
onChange({ ...draft, body: JSON.stringify(JSON.parse(draft.body), null, 2) })
|
|
150
|
+
} catch {
|
|
151
|
+
/* the invalid-JSON message is shown by the panel */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (encoding === 'graphql' && splitGraphql(draft.body))
|
|
156
|
+
return <GraphqlBody draft={draft} onChange={onChange} />
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<section class="body" aria-label="Body">
|
|
160
|
+
<div class="section-head">
|
|
161
|
+
<h3>Body</h3>
|
|
162
|
+
<span class="chip">{encoding}</span>
|
|
163
|
+
<span class="spacer" />
|
|
164
|
+
{encoding !== 'text' && (
|
|
165
|
+
<button type="button" class="link" onClick={format}>
|
|
166
|
+
Format
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
{viewToggle}
|
|
170
|
+
</div>
|
|
171
|
+
<BodyEditor
|
|
172
|
+
label="Request body"
|
|
173
|
+
language={language(encoding === 'form' ? 'json' : encoding)}
|
|
174
|
+
value={draft.body}
|
|
175
|
+
onChange={(body) => onChange({ ...draft, body })}
|
|
176
|
+
onLine={setLine}
|
|
177
|
+
complete={complete}
|
|
178
|
+
/>
|
|
179
|
+
{documented && <FieldDoc doc={doc} />}
|
|
180
|
+
</section>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createEditor, type PrismEditor } from 'prism-code-editor'
|
|
2
|
+
import { autoComplete, fuzzyFilter, registerCompletions } from 'prism-code-editor/autocomplete'
|
|
3
|
+
import { defaultKeymap, editHistory, editorCommands } from 'prism-code-editor/commands'
|
|
4
|
+
import { cursorPosition } from 'prism-code-editor/cursor'
|
|
5
|
+
import 'prism-code-editor/languages/json'
|
|
6
|
+
import 'prism-code-editor/languages/graphql'
|
|
7
|
+
import 'prism-code-editor/prism/languages/json'
|
|
8
|
+
import 'prism-code-editor/prism/languages/graphql'
|
|
9
|
+
import { useEffect, useRef } from 'preact/hooks'
|
|
10
|
+
import type { Completions } from './completions'
|
|
11
|
+
|
|
12
|
+
/** Per-editor completion providers; one global source dispatches to the editor it was asked about. */
|
|
13
|
+
const providers = new WeakMap<PrismEditor, (text: string, pos: number) => Completions | undefined>()
|
|
14
|
+
|
|
15
|
+
registerCompletions(['json'], {
|
|
16
|
+
sources: [
|
|
17
|
+
(ctx, editor) => {
|
|
18
|
+
const found = providers.get(editor)?.(editor.value, ctx.pos)
|
|
19
|
+
if (!found) return null
|
|
20
|
+
return {
|
|
21
|
+
from: found.from,
|
|
22
|
+
to: found.to,
|
|
23
|
+
options: found.options.map((o) => ({
|
|
24
|
+
...o,
|
|
25
|
+
renderDocs: o.doc ? () => [o.doc as string] : undefined,
|
|
26
|
+
})),
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
value: string
|
|
34
|
+
language?: 'json' | 'graphql' | 'text'
|
|
35
|
+
readOnly?: boolean
|
|
36
|
+
label: string
|
|
37
|
+
onChange?: (value: string) => void
|
|
38
|
+
/** 0-based line the cursor is on */
|
|
39
|
+
onLine?: (line: number) => void
|
|
40
|
+
/** schema-driven suggestions for keys and values (JSON bodies only) */
|
|
41
|
+
complete?: (text: string, pos: number) => Completions | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function BodyEditor({
|
|
45
|
+
value,
|
|
46
|
+
language = 'json',
|
|
47
|
+
readOnly,
|
|
48
|
+
label,
|
|
49
|
+
onChange,
|
|
50
|
+
onLine,
|
|
51
|
+
complete,
|
|
52
|
+
}: Props) {
|
|
53
|
+
const host = useRef<HTMLDivElement>(null)
|
|
54
|
+
const editor = useRef<PrismEditor | undefined>(undefined)
|
|
55
|
+
const on = useRef({ onChange, onLine })
|
|
56
|
+
on.current = { onChange, onLine }
|
|
57
|
+
const completer = useRef(complete)
|
|
58
|
+
completer.current = complete
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const e = createEditor(
|
|
62
|
+
host.current,
|
|
63
|
+
{
|
|
64
|
+
value,
|
|
65
|
+
language,
|
|
66
|
+
readOnly,
|
|
67
|
+
lineNumbers: false,
|
|
68
|
+
wordWrap: true,
|
|
69
|
+
onUpdate: (v) => on.current.onChange?.(v),
|
|
70
|
+
onSelectionChange: (_s, _v, ed) => on.current.onLine?.(ed.activeLine - 1),
|
|
71
|
+
},
|
|
72
|
+
editorCommands(defaultKeymap),
|
|
73
|
+
editHistory(),
|
|
74
|
+
...(complete ? [cursorPosition(), autoComplete({ filter: fuzzyFilter })] : []),
|
|
75
|
+
)
|
|
76
|
+
if (complete) providers.set(e, (text, pos) => completer.current?.(text, pos))
|
|
77
|
+
e.textarea.setAttribute('aria-label', label)
|
|
78
|
+
editor.current = e
|
|
79
|
+
return () => e.remove()
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const e = editor.current
|
|
84
|
+
if (e && e.value !== value) e.setOptions({ value })
|
|
85
|
+
}, [value])
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
editor.current?.setOptions({ readOnly, language })
|
|
89
|
+
}, [readOnly, language])
|
|
90
|
+
|
|
91
|
+
return <div class="body-editor" part="editor" ref={host} />
|
|
92
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthProfile,
|
|
3
|
+
type Encoding,
|
|
4
|
+
type RenderedRequest,
|
|
5
|
+
render,
|
|
6
|
+
type SnippetInput,
|
|
7
|
+
type SnippetLang,
|
|
8
|
+
snippetLangs,
|
|
9
|
+
toSnippet,
|
|
10
|
+
} from '@specbook/core'
|
|
11
|
+
import type { ComponentChildren } from 'preact'
|
|
12
|
+
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
|
13
|
+
import { engine } from '../runtime'
|
|
14
|
+
import { env, version } from '../store'
|
|
15
|
+
import { type Draft, requestParts } from './draft'
|
|
16
|
+
|
|
17
|
+
const LANG_KEY = 'specbook:code-lang'
|
|
18
|
+
|
|
19
|
+
function savedLang(): SnippetLang {
|
|
20
|
+
try {
|
|
21
|
+
const v = localStorage.getItem(LANG_KEY)
|
|
22
|
+
if (v === 'curl' || v === 'fetch') return v
|
|
23
|
+
} catch {
|
|
24
|
+
/* optional */
|
|
25
|
+
}
|
|
26
|
+
return 'curl'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Applied = { headers?: Record<string, string>; query?: Record<string, string> } | undefined
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
service: string
|
|
33
|
+
/** auth profile to apply: its name, `service` for the service's default, or nothing */
|
|
34
|
+
profile?: string
|
|
35
|
+
/** shown as a hint under the sample */
|
|
36
|
+
secured?: boolean
|
|
37
|
+
/** builds the request; keep it stable (useCallback) so values from faker do not change per render */
|
|
38
|
+
make: (base: string, applied: Applied) => SnippetInput
|
|
39
|
+
/** the request as it actually went out, offered as a third view once there is one */
|
|
40
|
+
sent?: ComponentChildren
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** cURL / Node fetch tabs with a copy button, for a request described by `make`. */
|
|
44
|
+
export function CodeSample({ service, profile, secured, make, sent }: Props) {
|
|
45
|
+
const [view, setView] = useState<SnippetLang | 'sent'>(savedLang)
|
|
46
|
+
const lang: SnippetLang = view === 'sent' ? 'curl' : view
|
|
47
|
+
const [copied, setCopied] = useState(false)
|
|
48
|
+
const [base, setBase] = useState<string>()
|
|
49
|
+
const [applied, setApplied] = useState<Applied>()
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let live = true
|
|
53
|
+
engine.config(version.value).then((c) => {
|
|
54
|
+
if (!live) return
|
|
55
|
+
const svc = c.services?.[service]
|
|
56
|
+
const urls = svc?.baseUrl ?? {}
|
|
57
|
+
// the environment being used, so a sample for the sandbox points at the sandbox
|
|
58
|
+
setBase(urls[env.value] ?? urls.live ?? Object.values(urls)[0])
|
|
59
|
+
const name = profile === 'service' ? svc?.auth : profile
|
|
60
|
+
setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
|
|
61
|
+
})
|
|
62
|
+
return () => {
|
|
63
|
+
live = false
|
|
64
|
+
}
|
|
65
|
+
}, [service, profile, env.value])
|
|
66
|
+
|
|
67
|
+
const built = useMemo<{ text?: string; error?: string }>(() => {
|
|
68
|
+
try {
|
|
69
|
+
return { text: toSnippet(lang, make(base ?? 'https://api.example.com', applied)) }
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { error: e instanceof Error ? e.message : String(e) }
|
|
72
|
+
}
|
|
73
|
+
}, [make, lang, base, applied])
|
|
74
|
+
|
|
75
|
+
const pick = (l: SnippetLang | 'sent') => {
|
|
76
|
+
setView(l)
|
|
77
|
+
if (l === 'sent') return
|
|
78
|
+
try {
|
|
79
|
+
localStorage.setItem(LANG_KEY, l)
|
|
80
|
+
} catch {
|
|
81
|
+
/* optional */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const copy = async () => {
|
|
86
|
+
if (!built.text) return
|
|
87
|
+
try {
|
|
88
|
+
await navigator.clipboard.writeText(built.text)
|
|
89
|
+
setCopied(true)
|
|
90
|
+
setTimeout(() => setCopied(false), 1500)
|
|
91
|
+
} catch {
|
|
92
|
+
/* clipboard blocked: the text is selectable */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div class="code-sample">
|
|
98
|
+
<div class="code-head">
|
|
99
|
+
<fieldset class="seg" aria-label="Code sample">
|
|
100
|
+
{snippetLangs.map((l) => (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
aria-pressed={view === l.id}
|
|
104
|
+
class={`seg-btn ${view === l.id ? 'on' : ''}`}
|
|
105
|
+
onClick={() => pick(l.id)}
|
|
106
|
+
>
|
|
107
|
+
{l.label}
|
|
108
|
+
</button>
|
|
109
|
+
))}
|
|
110
|
+
{sent !== undefined && (
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
aria-pressed={view === 'sent'}
|
|
114
|
+
class={`seg-btn ${view === 'sent' ? 'on' : ''}`}
|
|
115
|
+
onClick={() => pick('sent')}
|
|
116
|
+
>
|
|
117
|
+
Sent
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
</fieldset>
|
|
121
|
+
<span class="spacer" />
|
|
122
|
+
{view !== 'sent' && (
|
|
123
|
+
<button type="button" class="btn small" onClick={copy} disabled={!built.text}>
|
|
124
|
+
{copied ? 'Copied' : 'Copy'}
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
{view === 'sent' ? (
|
|
129
|
+
sent
|
|
130
|
+
) : built.error ? (
|
|
131
|
+
<p class="muted">{built.error}</p>
|
|
132
|
+
) : (
|
|
133
|
+
<pre class="code-block" part="code">
|
|
134
|
+
<code>{built.text}</code>
|
|
135
|
+
</pre>
|
|
136
|
+
)}
|
|
137
|
+
{view !== 'sent' && secured && (
|
|
138
|
+
<p class="muted small">Secrets are read from environment variables; set them first.</p>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface DraftProps {
|
|
145
|
+
service: string
|
|
146
|
+
method: string
|
|
147
|
+
/** the operation's path template, e.g. /pets/{petId} */
|
|
148
|
+
path: string
|
|
149
|
+
draft: Draft
|
|
150
|
+
encoding: Encoding
|
|
151
|
+
vars: Record<string, string>
|
|
152
|
+
secured?: boolean
|
|
153
|
+
sent?: ComponentChildren
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The sample for the request as currently edited in the panel. */
|
|
157
|
+
export function DraftCodeSample({
|
|
158
|
+
service,
|
|
159
|
+
method,
|
|
160
|
+
path,
|
|
161
|
+
draft,
|
|
162
|
+
encoding,
|
|
163
|
+
vars,
|
|
164
|
+
secured,
|
|
165
|
+
sent,
|
|
166
|
+
}: DraftProps) {
|
|
167
|
+
const make = useCallback(
|
|
168
|
+
(base: string, applied: Applied): SnippetInput => {
|
|
169
|
+
const parts = requestParts(draft, encoding)
|
|
170
|
+
const raw = {
|
|
171
|
+
path: parts.path ?? {},
|
|
172
|
+
query: parts.query ?? {},
|
|
173
|
+
headers: parts.headers ?? {},
|
|
174
|
+
data: parts.data,
|
|
175
|
+
}
|
|
176
|
+
let r = raw
|
|
177
|
+
try {
|
|
178
|
+
r = render(raw, vars)
|
|
179
|
+
} catch {
|
|
180
|
+
/* a variable is still unset: show the placeholders as they are */
|
|
181
|
+
}
|
|
182
|
+
const url = path.replace(/\{([^}]+)\}/g, (m, n: string) => {
|
|
183
|
+
const v = (r.path as Record<string, unknown>)[n]
|
|
184
|
+
return v === undefined || v === '' ? m : encodeURIComponent(String(v))
|
|
185
|
+
})
|
|
186
|
+
return {
|
|
187
|
+
method,
|
|
188
|
+
url: `${base.replace(/\/+$/, '')}${url}`,
|
|
189
|
+
query: { ...(applied?.query ?? {}), ...(r.query as Record<string, string | string[]>) },
|
|
190
|
+
headers: { ...(applied?.headers ?? {}), ...(r.headers as Record<string, string>) },
|
|
191
|
+
body: r.data,
|
|
192
|
+
encoding,
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
[draft, encoding, vars, method, path],
|
|
196
|
+
)
|
|
197
|
+
return (
|
|
198
|
+
<CodeSample
|
|
199
|
+
service={service}
|
|
200
|
+
profile={secured ? 'service' : undefined}
|
|
201
|
+
secured={secured}
|
|
202
|
+
make={make}
|
|
203
|
+
sent={sent}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The sample for a request a sequence step recorded (placeholders for secrets and session values kept). */
|
|
209
|
+
export function StepCodeSample({
|
|
210
|
+
request,
|
|
211
|
+
encoding,
|
|
212
|
+
sent,
|
|
213
|
+
}: {
|
|
214
|
+
request: RenderedRequest
|
|
215
|
+
encoding: Encoding
|
|
216
|
+
sent?: ComponentChildren
|
|
217
|
+
}) {
|
|
218
|
+
const make = useCallback(
|
|
219
|
+
(base: string, applied: Applied): SnippetInput => ({
|
|
220
|
+
method: request.method,
|
|
221
|
+
url: `${base.replace(/\/+$/, '')}${request.url}`,
|
|
222
|
+
query: { ...(applied?.query ?? {}), ...request.query },
|
|
223
|
+
headers: { ...(applied?.headers ?? {}), ...request.headers },
|
|
224
|
+
body: request.body,
|
|
225
|
+
encoding,
|
|
226
|
+
}),
|
|
227
|
+
[request, encoding],
|
|
228
|
+
)
|
|
229
|
+
return (
|
|
230
|
+
<CodeSample
|
|
231
|
+
service={request.service}
|
|
232
|
+
profile={request.profile}
|
|
233
|
+
secured={!!request.profile}
|
|
234
|
+
make={make}
|
|
235
|
+
sent={sent}
|
|
236
|
+
/>
|
|
237
|
+
)
|
|
238
|
+
}
|