@raystack/chronicle 0.7.4 → 0.9.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/cli/index.js +14 -2
- package/package.json +3 -4
- package/src/components/api/api-code-snippet.module.css +23 -0
- package/src/components/api/api-code-snippet.tsx +64 -0
- package/src/components/api/api-field-list.module.css +76 -0
- package/src/components/api/api-field-list.tsx +91 -0
- package/src/components/api/api-overview.module.css +65 -0
- package/src/components/api/api-overview.tsx +216 -0
- package/src/components/api/api-response-panel.module.css +62 -0
- package/src/components/api/api-response-panel.tsx +54 -0
- package/src/components/api/index.ts +5 -6
- package/src/components/api/json-editor.tsx +8 -8
- package/src/components/api/method-badge.tsx +2 -2
- package/src/components/api/playground-dialog.module.css +342 -0
- package/src/components/api/playground-dialog.tsx +583 -0
- package/src/components/ui/search.module.css +27 -5
- package/src/components/ui/search.tsx +28 -19
- package/src/lib/api-routes.ts +37 -8
- package/src/lib/openapi.ts +26 -0
- package/src/lib/page-context.tsx +1 -1
- package/src/lib/schema.ts +45 -3
- package/src/lib/source.ts +79 -13
- package/src/lib/use-api-operation.ts +15 -0
- package/src/pages/ApiLayout.module.css +1 -0
- package/src/pages/ApiPage.tsx +7 -38
- package/src/pages/DocsPage.tsx +40 -1
- package/src/server/api/apis-proxy.ts +8 -1
- package/src/server/api/ready.ts +15 -0
- package/src/server/api/search.ts +159 -85
- package/src/server/entry-server.tsx +1 -1
- package/src/server/routes/[...slug].md.ts +1 -0
- package/src/server/routes/apis/[...slug].md.ts +181 -0
- package/src/server/vite-config.ts +11 -0
- package/src/themes/default/Layout.module.css +53 -0
- package/src/themes/default/Layout.tsx +162 -11
- package/src/themes/default/Page.module.css +4 -0
- package/src/themes/default/Page.tsx +6 -1
- package/src/types/config.ts +2 -1
- package/src/components/api/code-snippets.module.css +0 -7
- package/src/components/api/code-snippets.tsx +0 -76
- package/src/components/api/endpoint-page.module.css +0 -58
- package/src/components/api/endpoint-page.tsx +0 -283
- package/src/components/api/field-row.module.css +0 -126
- package/src/components/api/field-row.tsx +0 -204
- package/src/components/api/field-section.module.css +0 -24
- package/src/components/api/field-section.tsx +0 -100
- package/src/components/api/key-value-editor.module.css +0 -13
- package/src/components/api/key-value-editor.tsx +0 -62
- package/src/components/api/response-panel.module.css +0 -8
- package/src/components/api/response-panel.tsx +0 -44
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useMemo } from 'react'
|
|
4
|
+
import type { OpenAPIV3 } from 'openapi-types'
|
|
5
|
+
import { Dialog, Button, Badge, IconButton, InputField, CopyButton, Select, Menu } from '@raystack/apsara'
|
|
6
|
+
import { Cross2Icon, ChevronDownIcon, ChevronUpIcon, PlayIcon, PlusIcon } from '@radix-ui/react-icons'
|
|
7
|
+
import { CounterClockwiseClockIcon, CodeIcon } from '@radix-ui/react-icons'
|
|
8
|
+
import { MethodBadge } from '@/components/api/method-badge'
|
|
9
|
+
import { flattenSchema, generateExampleJson, toKind, type SchemaField } from '@/lib/schema'
|
|
10
|
+
import { generateCurl } from '@/lib/snippet-generators'
|
|
11
|
+
import { JsonEditor } from '@/components/api/json-editor'
|
|
12
|
+
import styles from './playground-dialog.module.css'
|
|
13
|
+
|
|
14
|
+
type AuthScheme = {
|
|
15
|
+
name: string
|
|
16
|
+
type: 'apiKey' | 'bearer' | 'basic' | 'none'
|
|
17
|
+
headerName: string
|
|
18
|
+
placeholder: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getAuthSchemes(
|
|
22
|
+
document: OpenAPIV3.Document,
|
|
23
|
+
auth?: { type: string; header: string; placeholder?: string }
|
|
24
|
+
): AuthScheme[] {
|
|
25
|
+
const schemes: AuthScheme[] = [{ name: 'None', type: 'none', headerName: '', placeholder: '' }]
|
|
26
|
+
const securitySchemes = (document.components?.securitySchemes ?? {}) as Record<string, OpenAPIV3.SecuritySchemeObject>
|
|
27
|
+
|
|
28
|
+
for (const [name, scheme] of Object.entries(securitySchemes)) {
|
|
29
|
+
if (scheme.type === 'apiKey' && 'name' in scheme && 'in' in scheme && scheme.in === 'header') {
|
|
30
|
+
schemes.push({ name: `API Key (${scheme.name ?? name})`, type: 'apiKey', headerName: scheme.name ?? name, placeholder: 'Enter API key' })
|
|
31
|
+
} else if (scheme.type === 'http' && 'scheme' in scheme) {
|
|
32
|
+
if (scheme.scheme === 'bearer') {
|
|
33
|
+
schemes.push({ name: 'Bearer Token', type: 'bearer', headerName: 'Authorization', placeholder: 'Enter bearer token' })
|
|
34
|
+
} else if (scheme.scheme === 'basic') {
|
|
35
|
+
schemes.push({ name: 'Basic Auth', type: 'basic', headerName: 'Authorization', placeholder: '' })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (auth && !schemes.some((s) => s.headerName === auth.header && s.type !== 'none')) {
|
|
41
|
+
schemes.push({ name: `API Key (${auth.header})`, type: 'apiKey', headerName: auth.header, placeholder: auth.placeholder ?? 'Enter API key' })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return schemes
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PlaygroundDialogProps {
|
|
48
|
+
open: boolean
|
|
49
|
+
onOpenChange: (open: boolean) => void
|
|
50
|
+
method: string
|
|
51
|
+
path: string
|
|
52
|
+
operation: OpenAPIV3.OperationObject
|
|
53
|
+
serverUrl: string
|
|
54
|
+
specName: string
|
|
55
|
+
auth?: { type: string; header: string; placeholder?: string }
|
|
56
|
+
document: OpenAPIV3.Document
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function PlaygroundDialog({
|
|
60
|
+
open, onOpenChange, method, path, operation, serverUrl, specName, auth, document,
|
|
61
|
+
}: PlaygroundDialogProps) {
|
|
62
|
+
const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[]
|
|
63
|
+
const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined)
|
|
64
|
+
|
|
65
|
+
const headerFields = paramsToFields(params.filter((p) => p.in === 'header'))
|
|
66
|
+
const pathFields = paramsToFields(params.filter((p) => p.in === 'path'))
|
|
67
|
+
const queryFields = paramsToFields(params.filter((p) => p.in === 'query'))
|
|
68
|
+
|
|
69
|
+
const authSchemes = useMemo(() => getAuthSchemes(document, auth), [document, auth])
|
|
70
|
+
const defaultScheme = authSchemes.find((s) => s.type !== 'none') ?? authSchemes[0]
|
|
71
|
+
|
|
72
|
+
const [selectedScheme, setSelectedScheme] = useState(defaultScheme.name)
|
|
73
|
+
const [authToken, setAuthToken] = useState('')
|
|
74
|
+
const [basicUser, setBasicUser] = useState('')
|
|
75
|
+
const [basicPass, setBasicPass] = useState('')
|
|
76
|
+
const [headerValues, setHeaderValues] = useState<Record<string, string>>({})
|
|
77
|
+
const [pathValues, setPathValues] = useState<Record<string, string>>({})
|
|
78
|
+
const [queryValues, setQueryValues] = useState<Record<string, string>>({})
|
|
79
|
+
const [jsonMode, setJsonMode] = useState(false)
|
|
80
|
+
const [bodyValues, setBodyValues] = useState<Record<string, unknown>>(() => {
|
|
81
|
+
if (!body) return {}
|
|
82
|
+
const init: Record<string, unknown> = {}
|
|
83
|
+
for (const f of body.fields) {
|
|
84
|
+
if (f.kind === 'array') init[f.name] = []
|
|
85
|
+
else if (f.kind === 'object' || f.children) init[f.name] = {}
|
|
86
|
+
else init[f.name] = ''
|
|
87
|
+
}
|
|
88
|
+
return init
|
|
89
|
+
})
|
|
90
|
+
const [bodyJsonStr, setBodyJsonStr] = useState(() => body ? body.jsonExample : '{}')
|
|
91
|
+
|
|
92
|
+
const [responseData, setResponseData] = useState<{
|
|
93
|
+
status: number; statusText: string; body: unknown; headers?: Record<string, string>; time: number
|
|
94
|
+
} | null>(null)
|
|
95
|
+
const [responseView, setResponseView] = useState<'body' | 'headers'>('body')
|
|
96
|
+
const [loading, setLoading] = useState(false)
|
|
97
|
+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
|
98
|
+
|
|
99
|
+
const toggleCollapse = (section: string) => {
|
|
100
|
+
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const currentScheme = authSchemes.find((s) => s.name === selectedScheme) ?? authSchemes[0]
|
|
104
|
+
|
|
105
|
+
const getAuthHeaders = useCallback((): Record<string, string> => {
|
|
106
|
+
const headers: Record<string, string> = {}
|
|
107
|
+
if (currentScheme.type === 'apiKey' && authToken) {
|
|
108
|
+
headers[currentScheme.headerName] = authToken
|
|
109
|
+
} else if (currentScheme.type === 'bearer' && authToken) {
|
|
110
|
+
headers['Authorization'] = `Bearer ${authToken}`
|
|
111
|
+
} else if (currentScheme.type === 'basic' && (basicUser || basicPass)) {
|
|
112
|
+
headers['Authorization'] = `Basic ${btoa(`${basicUser}:${basicPass}`)}`
|
|
113
|
+
}
|
|
114
|
+
return headers
|
|
115
|
+
}, [currentScheme, authToken, basicUser, basicPass])
|
|
116
|
+
|
|
117
|
+
const handleReset = () => {
|
|
118
|
+
setSelectedScheme(defaultScheme.name)
|
|
119
|
+
setAuthToken('')
|
|
120
|
+
setBasicUser('')
|
|
121
|
+
setBasicPass('')
|
|
122
|
+
setHeaderValues({})
|
|
123
|
+
setPathValues({})
|
|
124
|
+
setQueryValues({})
|
|
125
|
+
setBodyValues(() => {
|
|
126
|
+
if (!body) return {}
|
|
127
|
+
const init: Record<string, unknown> = {}
|
|
128
|
+
for (const f of body.fields) {
|
|
129
|
+
if (f.kind === 'array') init[f.name] = []
|
|
130
|
+
else if (f.kind === 'object' || f.children) init[f.name] = {}
|
|
131
|
+
else init[f.name] = ''
|
|
132
|
+
}
|
|
133
|
+
return init
|
|
134
|
+
})
|
|
135
|
+
setResponseData(null)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const handleSend = useCallback(async () => {
|
|
139
|
+
setLoading(true)
|
|
140
|
+
setResponseData(null)
|
|
141
|
+
const startTime = performance.now()
|
|
142
|
+
|
|
143
|
+
let resolvedPath = path
|
|
144
|
+
for (const [key, value] of Object.entries(pathValues)) {
|
|
145
|
+
if (value) resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(value))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const queryEntries = Object.entries(queryValues).filter(([, v]) => v)
|
|
149
|
+
const queryString = queryEntries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
|
150
|
+
const fullPath = queryString ? `${resolvedPath}?${queryString}` : resolvedPath
|
|
151
|
+
|
|
152
|
+
const reqHeaders: Record<string, string> = { ...getAuthHeaders() }
|
|
153
|
+
for (const [key, value] of Object.entries(headerValues)) {
|
|
154
|
+
if (value) reqHeaders[key] = value
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let reqBody: unknown = undefined
|
|
158
|
+
if (body) {
|
|
159
|
+
reqHeaders['Content-Type'] = body.contentType ?? 'application/json'
|
|
160
|
+
if (jsonMode) {
|
|
161
|
+
try {
|
|
162
|
+
reqBody = JSON.parse(bodyJsonStr)
|
|
163
|
+
} catch {
|
|
164
|
+
setResponseData({ status: 0, statusText: 'Error', body: 'Invalid JSON in request body', time: 0 })
|
|
165
|
+
setLoading(false)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
reqBody = bodyValues
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch('/api/apis-proxy', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ specName, method, path: fullPath, headers: reqHeaders, body: reqBody }),
|
|
178
|
+
})
|
|
179
|
+
const data = await res.json()
|
|
180
|
+
const elapsed = Math.round(performance.now() - startTime)
|
|
181
|
+
if (data.status !== undefined) {
|
|
182
|
+
setResponseData({ ...data, time: elapsed })
|
|
183
|
+
} else {
|
|
184
|
+
setResponseData({ status: res.status, statusText: res.statusText, body: data.error ?? data, time: elapsed })
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
setResponseData({ status: 0, statusText: 'Error', body: 'Failed to send request', time: 0 })
|
|
188
|
+
} finally {
|
|
189
|
+
setLoading(false)
|
|
190
|
+
}
|
|
191
|
+
}, [specName, method, path, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body])
|
|
192
|
+
|
|
193
|
+
const responseJson = responseData
|
|
194
|
+
? (typeof responseData.body === 'string' ? responseData.body : JSON.stringify(responseData.body, null, 2))
|
|
195
|
+
: ''
|
|
196
|
+
|
|
197
|
+
const responseLines = responseJson ? responseJson.split('\n') : []
|
|
198
|
+
|
|
199
|
+
const curlSnippet = useMemo(() => {
|
|
200
|
+
const headers: Record<string, string> = { ...getAuthHeaders(), ...headerValues }
|
|
201
|
+
if (body) headers['Content-Type'] = body.contentType ?? 'application/json'
|
|
202
|
+
const bodyStr = body ? (jsonMode ? bodyJsonStr : JSON.stringify(bodyValues)) : undefined
|
|
203
|
+
return generateCurl({ method, url: serverUrl + path, headers, body: bodyStr })
|
|
204
|
+
}, [method, path, serverUrl, getAuthHeaders, headerValues, bodyValues, bodyJsonStr, jsonMode, body])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
209
|
+
<Dialog.Content className={styles.dialog} showCloseButton={false}>
|
|
210
|
+
{/* Action Nav */}
|
|
211
|
+
<div className={styles.actionNav}>
|
|
212
|
+
<span className={styles.actionNavTitle}>{operation.summary ?? `${method} ${path}`}</span>
|
|
213
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--rs-space-3)' }}>
|
|
214
|
+
<IconButton size={2} onClick={handleReset} aria-label="Reset">
|
|
215
|
+
<CounterClockwiseClockIcon />
|
|
216
|
+
</IconButton>
|
|
217
|
+
<IconButton size={2} onClick={() => onOpenChange(false)} aria-label="Close">
|
|
218
|
+
<Cross2Icon />
|
|
219
|
+
</IconButton>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Split Panel */}
|
|
224
|
+
<div className={styles.splitPanel}>
|
|
225
|
+
{/* Left Panel */}
|
|
226
|
+
<div className={styles.leftPanel}>
|
|
227
|
+
<div className={styles.panelHeader}>
|
|
228
|
+
<span className={styles.panelTitle}>Test request</span>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Fields */}
|
|
232
|
+
<div className={styles.fieldsScroll}>
|
|
233
|
+
{/* Auth Section */}
|
|
234
|
+
{(
|
|
235
|
+
<>
|
|
236
|
+
<div className={styles.sectionHeader}>
|
|
237
|
+
<span className={styles.sectionLabel}>Authorization</span>
|
|
238
|
+
<IconButton size={2} onClick={() => toggleCollapse('auth')}>
|
|
239
|
+
{collapsed.auth ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
|
240
|
+
</IconButton>
|
|
241
|
+
</div>
|
|
242
|
+
{!collapsed.auth && (
|
|
243
|
+
<>
|
|
244
|
+
{authSchemes.length > 2 && (
|
|
245
|
+
<div className={styles.fieldRow}>
|
|
246
|
+
<span className={styles.fieldLabel}>Type</span>
|
|
247
|
+
<div className={styles.fieldInput}>
|
|
248
|
+
<Select value={selectedScheme} onValueChange={setSelectedScheme}>
|
|
249
|
+
<Select.Trigger size="small">
|
|
250
|
+
<Select.Value />
|
|
251
|
+
</Select.Trigger>
|
|
252
|
+
<Select.Content>
|
|
253
|
+
{authSchemes.map((s) => (
|
|
254
|
+
<Select.Item key={s.name} value={s.name}>{s.name}</Select.Item>
|
|
255
|
+
))}
|
|
256
|
+
</Select.Content>
|
|
257
|
+
</Select>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
{currentScheme.type === 'basic' ? (
|
|
262
|
+
<>
|
|
263
|
+
<div className={styles.fieldRow}>
|
|
264
|
+
<span className={styles.fieldLabel}>Username</span>
|
|
265
|
+
<div className={styles.fieldInput}>
|
|
266
|
+
<InputField size="small" placeholder="Enter username" value={basicUser} onChange={(e) => setBasicUser(e.target.value)} />
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
<div className={styles.fieldRow}>
|
|
270
|
+
<span className={styles.fieldLabel}>Password</span>
|
|
271
|
+
<div className={styles.fieldInput}>
|
|
272
|
+
<InputField size="small" type="password" placeholder="Enter password" value={basicPass} onChange={(e) => setBasicPass(e.target.value)} />
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</>
|
|
276
|
+
) : currentScheme.type !== 'none' ? (
|
|
277
|
+
<div className={styles.fieldRow}>
|
|
278
|
+
<span className={styles.fieldLabel}>{currentScheme.headerName}</span>
|
|
279
|
+
<div className={styles.fieldInput}>
|
|
280
|
+
<InputField size="small" placeholder={currentScheme.placeholder} value={authToken} onChange={(e) => setAuthToken(e.target.value)} />
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
) : null}
|
|
284
|
+
{headerFields.filter((f) => f.name !== currentScheme.headerName).map((f) => (
|
|
285
|
+
<div key={f.name} className={styles.fieldRow}>
|
|
286
|
+
<span className={styles.fieldLabel}>{f.name}</span>
|
|
287
|
+
<div className={styles.fieldInput}>
|
|
288
|
+
<InputField size="small" placeholder="Enter value" value={headerValues[f.name] ?? ''} onChange={(e) => setHeaderValues({ ...headerValues, [f.name]: e.target.value })} />
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
))}
|
|
292
|
+
</>
|
|
293
|
+
)}
|
|
294
|
+
</>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{/* Path Params */}
|
|
298
|
+
{pathFields.length > 0 && (
|
|
299
|
+
<>
|
|
300
|
+
<div className={styles.sectionHeader}>
|
|
301
|
+
<span className={styles.sectionLabel}>Path Parameters</span>
|
|
302
|
+
<IconButton size={2} onClick={() => toggleCollapse('path')}>
|
|
303
|
+
{collapsed.path ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
|
304
|
+
</IconButton>
|
|
305
|
+
</div>
|
|
306
|
+
{!collapsed.path && pathFields.map((f) => (
|
|
307
|
+
<div key={f.name} className={styles.fieldRow}>
|
|
308
|
+
<span className={styles.fieldLabel}>{f.name}</span>
|
|
309
|
+
<div className={styles.fieldInput}>
|
|
310
|
+
<InputField
|
|
311
|
+
size="small"
|
|
312
|
+
placeholder="Enter value"
|
|
313
|
+
value={pathValues[f.name] ?? ''}
|
|
314
|
+
onChange={(e) => setPathValues({ ...pathValues, [f.name]: e.target.value })}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
))}
|
|
319
|
+
</>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{/* Query Params */}
|
|
323
|
+
{queryFields.length > 0 && (
|
|
324
|
+
<>
|
|
325
|
+
<div className={styles.sectionHeader}>
|
|
326
|
+
<span className={styles.sectionLabel}>Query Parameters</span>
|
|
327
|
+
<IconButton size={2} onClick={() => toggleCollapse('query')}>
|
|
328
|
+
{collapsed.query ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
|
329
|
+
</IconButton>
|
|
330
|
+
</div>
|
|
331
|
+
{!collapsed.query && queryFields.map((f) => (
|
|
332
|
+
<div key={f.name} className={styles.fieldRow}>
|
|
333
|
+
<span className={styles.fieldLabel}>{f.name}</span>
|
|
334
|
+
<div className={styles.fieldInput}>
|
|
335
|
+
<InputField
|
|
336
|
+
size="small"
|
|
337
|
+
placeholder={f.description ?? 'Enter value'}
|
|
338
|
+
value={queryValues[f.name] ?? ''}
|
|
339
|
+
onChange={(e) => setQueryValues({ ...queryValues, [f.name]: e.target.value })}
|
|
340
|
+
/>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
))}
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* Body Section */}
|
|
348
|
+
{body && (
|
|
349
|
+
<>
|
|
350
|
+
<div className={styles.sectionHeader}>
|
|
351
|
+
<span className={styles.sectionLabel}>Body</span>
|
|
352
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--rs-space-3)' }}>
|
|
353
|
+
<IconButton size={2} onClick={() => {
|
|
354
|
+
if (!jsonMode) {
|
|
355
|
+
setBodyJsonStr(JSON.stringify(bodyValues, null, 2))
|
|
356
|
+
} else {
|
|
357
|
+
try { setBodyValues(JSON.parse(bodyJsonStr)) } catch { /* ignore */ }
|
|
358
|
+
}
|
|
359
|
+
setJsonMode(!jsonMode)
|
|
360
|
+
}}>
|
|
361
|
+
<CodeIcon />
|
|
362
|
+
</IconButton>
|
|
363
|
+
<IconButton size={2} onClick={() => toggleCollapse('body')}>
|
|
364
|
+
{collapsed.body ? <ChevronDownIcon /> : <ChevronUpIcon />}
|
|
365
|
+
</IconButton>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
{!collapsed.body && (
|
|
369
|
+
jsonMode ? (
|
|
370
|
+
<div className={styles.jsonEditorWrap}>
|
|
371
|
+
<JsonEditor
|
|
372
|
+
value={bodyJsonStr}
|
|
373
|
+
onChange={(val) => setBodyJsonStr(val)}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
) : (
|
|
377
|
+
body.fields.map((f) => (
|
|
378
|
+
<BodyFieldRow
|
|
379
|
+
key={f.name}
|
|
380
|
+
field={f}
|
|
381
|
+
value={bodyValues[f.name]}
|
|
382
|
+
onChange={(val) => setBodyValues({ ...bodyValues, [f.name]: val })}
|
|
383
|
+
/>
|
|
384
|
+
))
|
|
385
|
+
)
|
|
386
|
+
)}
|
|
387
|
+
</>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* Right Panel */}
|
|
393
|
+
<div className={styles.rightPanel}>
|
|
394
|
+
<div className={styles.responseHeader}>
|
|
395
|
+
<span className={styles.panelTitle}>Response</span>
|
|
396
|
+
{responseData && (
|
|
397
|
+
<Menu>
|
|
398
|
+
<Menu.Trigger render={<Button variant="text" color="neutral" size="small" trailingIcon={<ChevronDownIcon />} />}>
|
|
399
|
+
{responseView === 'body' ? 'Body' : 'Headers'}
|
|
400
|
+
</Menu.Trigger>
|
|
401
|
+
<Menu.Content>
|
|
402
|
+
<Menu.Item onClick={() => setResponseView('body')}>Body</Menu.Item>
|
|
403
|
+
<Menu.Item onClick={() => setResponseView('headers')}>Headers</Menu.Item>
|
|
404
|
+
</Menu.Content>
|
|
405
|
+
</Menu>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
{responseData ? (
|
|
410
|
+
<>
|
|
411
|
+
<div className={styles.statusBar}>
|
|
412
|
+
<span className={styles.statusText}>
|
|
413
|
+
Status : <span className={styles.statusValue}>{responseData.status}</span>
|
|
414
|
+
</span>
|
|
415
|
+
<div className={styles.statusSeparator} />
|
|
416
|
+
<span className={styles.statusText}>
|
|
417
|
+
Time : <span className={styles.statusValue}>{responseData.time} ms</span>
|
|
418
|
+
</span>
|
|
419
|
+
</div>
|
|
420
|
+
{responseView === 'body' ? (
|
|
421
|
+
<div className={styles.codeArea}>
|
|
422
|
+
<div className={styles.lineNumbers}>
|
|
423
|
+
{responseLines.map((_, i) => (
|
|
424
|
+
<div key={i}>{i + 1}</div>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
<pre className={styles.responseCode}>{responseJson}</pre>
|
|
428
|
+
</div>
|
|
429
|
+
) : (
|
|
430
|
+
<div className={styles.headersArea}>
|
|
431
|
+
{responseData.headers ? (
|
|
432
|
+
Object.entries(responseData.headers).map(([k, v]) => (
|
|
433
|
+
<div key={k} className={styles.headerRow}>
|
|
434
|
+
<span className={styles.headerKey}>{k}</span>
|
|
435
|
+
<span className={styles.headerValue}>{v}</span>
|
|
436
|
+
</div>
|
|
437
|
+
))
|
|
438
|
+
) : (
|
|
439
|
+
<div className={styles.emptyResponse}>No headers available</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
</>
|
|
444
|
+
) : (
|
|
445
|
+
<div className={styles.emptyResponse}>
|
|
446
|
+
{loading ? 'Sending...' : 'Send a request to see the response'}
|
|
447
|
+
</div>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
{/* Bottom Bar */}
|
|
453
|
+
<div className={styles.bottomBar}>
|
|
454
|
+
<div className={styles.pathBar}>
|
|
455
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
456
|
+
<MethodBadge method={method} size="micro" />
|
|
457
|
+
<span className={styles.pathText}>{path}</span>
|
|
458
|
+
</div>
|
|
459
|
+
<CopyButton text={curlSnippet} size={2} />
|
|
460
|
+
</div>
|
|
461
|
+
<Button
|
|
462
|
+
variant="solid"
|
|
463
|
+
color="accent"
|
|
464
|
+
size="small"
|
|
465
|
+
trailingIcon={<PlayIcon />}
|
|
466
|
+
onClick={handleSend}
|
|
467
|
+
disabled={loading}
|
|
468
|
+
>
|
|
469
|
+
{loading ? 'Sending...' : 'Send'}
|
|
470
|
+
</Button>
|
|
471
|
+
</div>
|
|
472
|
+
</Dialog.Content>
|
|
473
|
+
</Dialog>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function BodyFieldRow({ field, value, onChange }: {
|
|
478
|
+
field: SchemaField
|
|
479
|
+
value: unknown
|
|
480
|
+
onChange: (val: unknown) => void
|
|
481
|
+
}) {
|
|
482
|
+
const hasChildren = field.children && field.children.length > 0
|
|
483
|
+
|
|
484
|
+
if (field.kind === 'array' && !hasChildren) {
|
|
485
|
+
const items = Array.isArray(value) ? value as string[] : []
|
|
486
|
+
return (
|
|
487
|
+
<div className={styles.arrayField}>
|
|
488
|
+
<div className={styles.fieldRow}>
|
|
489
|
+
<span className={styles.fieldLabel}>{field.name} {field.required && <Badge variant="danger" size="micro">required</Badge>}</span>
|
|
490
|
+
<IconButton size={2} onClick={() => onChange([...items, ''])} aria-label="Add item">
|
|
491
|
+
<PlusIcon />
|
|
492
|
+
</IconButton>
|
|
493
|
+
</div>
|
|
494
|
+
{items.map((item, i) => (
|
|
495
|
+
<div key={i} className={styles.arrayItemRow}>
|
|
496
|
+
<div className={styles.fieldInput}>
|
|
497
|
+
<InputField
|
|
498
|
+
size="small"
|
|
499
|
+
placeholder={`${field.name}[${i}]`}
|
|
500
|
+
value={String(item)}
|
|
501
|
+
onChange={(e) => {
|
|
502
|
+
const updated = [...items]
|
|
503
|
+
updated[i] = e.target.value
|
|
504
|
+
onChange(updated)
|
|
505
|
+
}}
|
|
506
|
+
/>
|
|
507
|
+
</div>
|
|
508
|
+
<IconButton size={2} onClick={() => onChange(items.filter((_, j) => j !== i))} aria-label="Remove item">
|
|
509
|
+
<Cross2Icon />
|
|
510
|
+
</IconButton>
|
|
511
|
+
</div>
|
|
512
|
+
))}
|
|
513
|
+
</div>
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (hasChildren) {
|
|
518
|
+
const objValue = (typeof value === 'object' && value !== null ? value : {}) as Record<string, unknown>
|
|
519
|
+
return (
|
|
520
|
+
<div className={styles.arrayField}>
|
|
521
|
+
<div className={styles.sectionHeader}>
|
|
522
|
+
<span className={styles.sectionLabel}>{field.name}</span>
|
|
523
|
+
</div>
|
|
524
|
+
<div className={styles.nestedFields}>
|
|
525
|
+
{field.children!.map((child) => (
|
|
526
|
+
<BodyFieldRow
|
|
527
|
+
key={child.name}
|
|
528
|
+
field={child}
|
|
529
|
+
value={objValue[child.name]}
|
|
530
|
+
onChange={(val) => onChange({ ...objValue, [child.name]: val })}
|
|
531
|
+
/>
|
|
532
|
+
))}
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<div className={styles.fieldRow}>
|
|
540
|
+
<span className={styles.fieldLabel}>{field.name} {field.required && <Badge variant="danger" size="micro">required</Badge>}</span>
|
|
541
|
+
<div className={styles.fieldInput}>
|
|
542
|
+
<InputField
|
|
543
|
+
size="small"
|
|
544
|
+
placeholder={field.description ?? 'Enter value'}
|
|
545
|
+
value={String(value ?? '')}
|
|
546
|
+
onChange={(e) => onChange(e.target.value)}
|
|
547
|
+
/>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function paramsToFields(params: OpenAPIV3.ParameterObject[]): SchemaField[] {
|
|
554
|
+
return params.map((p) => {
|
|
555
|
+
const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject
|
|
556
|
+
return {
|
|
557
|
+
name: p.name,
|
|
558
|
+
type: schema.type ? String(schema.type) : 'string',
|
|
559
|
+
kind: toKind(schema.type),
|
|
560
|
+
required: p.required ?? false,
|
|
561
|
+
description: p.description,
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
interface RequestBody {
|
|
567
|
+
contentType: string
|
|
568
|
+
fields: SchemaField[]
|
|
569
|
+
jsonExample: string
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function getRequestBody(body: OpenAPIV3.RequestBodyObject | undefined): RequestBody | null {
|
|
573
|
+
if (!body?.content) return null
|
|
574
|
+
const contentType = Object.keys(body.content)[0]
|
|
575
|
+
if (!contentType) return null
|
|
576
|
+
const schema = body.content[contentType]?.schema as OpenAPIV3.SchemaObject | undefined
|
|
577
|
+
if (!schema) return null
|
|
578
|
+
return {
|
|
579
|
+
contentType,
|
|
580
|
+
fields: flattenSchema(schema),
|
|
581
|
+
jsonExample: JSON.stringify(generateExampleJson(schema), null, 2),
|
|
582
|
+
}
|
|
583
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
.list {
|
|
15
15
|
max-height: 400px;
|
|
16
|
+
gap: var(--rs-space-3);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
.list :global([cmdk-group-heading]) {
|
|
@@ -24,13 +25,14 @@
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
.item {
|
|
27
|
-
height:
|
|
28
|
+
min-height: 40px;
|
|
28
29
|
padding: var(--rs-space-3);
|
|
29
30
|
gap: var(--rs-space-3);
|
|
30
31
|
border-radius: var(--rs-radius-2);
|
|
31
32
|
cursor: pointer;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
|
|
34
36
|
.item[data-selected="true"] {
|
|
35
37
|
background: var(--rs-color-background-base-primary-hover);
|
|
36
38
|
}
|
|
@@ -43,8 +45,9 @@
|
|
|
43
45
|
|
|
44
46
|
.resultText {
|
|
45
47
|
display: flex;
|
|
46
|
-
|
|
47
|
-
gap:
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: 2px;
|
|
50
|
+
min-width: 0;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
.headingText {
|
|
@@ -68,16 +71,35 @@
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
.icon {
|
|
71
|
-
width:
|
|
72
|
-
height:
|
|
74
|
+
width: 48px;
|
|
75
|
+
height: 24px;
|
|
73
76
|
color: var(--rs-color-foreground-base-secondary);
|
|
74
77
|
flex-shrink: 0;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
.itemContent :global([class*="badge-module"]) {
|
|
81
|
+
min-width: 48px;
|
|
82
|
+
justify-content: center;
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
.item[data-selected="true"] .icon {
|
|
78
86
|
color: var(--rs-color-foreground-accent-primary-hover);
|
|
79
87
|
}
|
|
80
88
|
|
|
89
|
+
.snippetText {
|
|
90
|
+
font-size: var(--rs-font-size-mini);
|
|
91
|
+
line-height: var(--rs-line-height-mini);
|
|
92
|
+
color: var(--rs-color-foreground-base-tertiary);
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
text-overflow: ellipsis;
|
|
95
|
+
white-space: nowrap;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.matchHighlight {
|
|
99
|
+
color: var(--rs-color-foreground-accent-primary);
|
|
100
|
+
font-weight: var(--rs-font-weight-medium);
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
.pageText :global(mark),
|
|
82
104
|
.headingText :global(mark) {
|
|
83
105
|
background: transparent;
|