@bielu/scalar-grpc 0.1.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/README.md +83 -0
- package/dist/plugin.js +24 -0
- package/dist/standalone.js +2410 -0
- package/dist/types/auth.d.ts +19 -0
- package/dist/types/components/GrpcConsole.vue.d.ts +7 -0
- package/dist/types/discovery.d.ts +13 -0
- package/dist/types/grpc-bindings.d.ts +11 -0
- package/dist/types/grpc-client.d.ts +39 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/plugin.d.ts +32 -0
- package/dist/types/types.d.ts +78 -0
- package/package.json +57 -0
- package/src/auth.ts +93 -0
- package/src/components/GrpcConsole.vue +759 -0
- package/src/discovery.ts +21 -0
- package/src/grpc-bindings.ts +119 -0
- package/src/grpc-client.ts +159 -0
- package/src/index.ts +32 -0
- package/src/plugin.ts +39 -0
- package/src/types.ts +88 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { JsonValue } from '@bufbuild/protobuf'
|
|
3
|
+
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
4
|
+
import { getAuthState, resolveGrpcAuth } from '../auth'
|
|
5
|
+
import { resolveDocuments } from '../discovery'
|
|
6
|
+
import { loadGrpcServices } from '../grpc-bindings'
|
|
7
|
+
import { descriptorsUrlFor, invokeServerStreaming, invokeUnary, loadDescriptorRegistry } from '../grpc-client'
|
|
8
|
+
import type { GrpcDocumentRef, GrpcMethodModel, GrpcServiceModel } from '../types'
|
|
9
|
+
|
|
10
|
+
// `options` is the Scalar configuration Scalar binds to the element; `documents` is an optional
|
|
11
|
+
// explicit override. Documents are otherwise auto-discovered from `options.sources`.
|
|
12
|
+
const props = defineProps<{ options?: Record<string, any>; documents?: GrpcDocumentRef[] }>()
|
|
13
|
+
|
|
14
|
+
type LogEntry = { time: string; dir: 'in' | 'out' | 'sys'; text: string }
|
|
15
|
+
|
|
16
|
+
const loading = ref(true)
|
|
17
|
+
const services = ref<GrpcServiceModel[]>([])
|
|
18
|
+
// The console's own root element, used to discover which Scalar document it is rendered under.
|
|
19
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
20
|
+
// Scalar's active document slug (null on stock Scalar / standalone use, where the picker is shown).
|
|
21
|
+
const activeDocumentSlug = ref<string | null>(null)
|
|
22
|
+
const selectedDocumentName = ref<string | null>(null)
|
|
23
|
+
const selectedKey = ref<string | null>(null)
|
|
24
|
+
const baseUrlOverride = ref('')
|
|
25
|
+
const invoking = ref(false)
|
|
26
|
+
const log = reactive<LogEntry[]>([])
|
|
27
|
+
// Per-method state survives service switches, so it is keyed by service + method id (methods from
|
|
28
|
+
// different services/documents can share an `id`, which would otherwise overwrite each other).
|
|
29
|
+
const requestJson = reactive<Record<string, string>>({})
|
|
30
|
+
const metadataJson = reactive<Record<string, string>>({})
|
|
31
|
+
const results = reactive<Record<string, { ok: boolean; text: string }>>({})
|
|
32
|
+
// Which method is selected per service (keyed by serviceKey), and which message is the active example.
|
|
33
|
+
const selectedMethodIds = reactive<Record<string, string>>({})
|
|
34
|
+
const selectedMessageIndex = reactive<Record<string, number>>({})
|
|
35
|
+
|
|
36
|
+
const serviceKey = (service: GrpcServiceModel) => `${service.documentName}:${service.channelName}`
|
|
37
|
+
// A service-scoped key for a method. All displayed methods belong to the selected service, so the
|
|
38
|
+
// selected service key uniquely scopes them.
|
|
39
|
+
const methodKey = (method: GrpcMethodModel) => `${selectedKey.value}:${method.id}`
|
|
40
|
+
|
|
41
|
+
const uniqueDocuments = computed(() => {
|
|
42
|
+
const seen = new Set<string>()
|
|
43
|
+
const docs: string[] = []
|
|
44
|
+
for (const service of services.value) {
|
|
45
|
+
if (!seen.has(service.documentName)) {
|
|
46
|
+
seen.add(service.documentName)
|
|
47
|
+
docs.push(service.documentName)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return docs
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Scope the visible services to Scalar's active document when known (so the console reflects the
|
|
54
|
+
// document the user is viewing), otherwise to the manually picked document. Falls back to all
|
|
55
|
+
// services only when neither is set.
|
|
56
|
+
const scopedDocumentName = computed(() => activeDocumentSlug.value ?? selectedDocumentName.value)
|
|
57
|
+
const filteredServices = computed(() =>
|
|
58
|
+
scopedDocumentName.value
|
|
59
|
+
? services.value.filter((service) => service.documentName === scopedDocumentName.value)
|
|
60
|
+
: services.value,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// The document picker only makes sense when the console spans several documents itself. Once it is
|
|
64
|
+
// scoped to Scalar's active document, the active document is authoritative, so the picker is hidden.
|
|
65
|
+
const showDocumentPicker = computed(() => !activeDocumentSlug.value && uniqueDocuments.value.length > 1)
|
|
66
|
+
|
|
67
|
+
const selectedService = computed(
|
|
68
|
+
() => filteredServices.value.find((service) => serviceKey(service) === selectedKey.value) ?? null,
|
|
69
|
+
)
|
|
70
|
+
// The selected method id for the current service, persisted per service so switching keeps each choice.
|
|
71
|
+
const selectedMethodId = computed<string | null>({
|
|
72
|
+
get: () => (selectedKey.value ? selectedMethodIds[selectedKey.value] ?? null : null),
|
|
73
|
+
set: (value) => {
|
|
74
|
+
if (selectedKey.value && value) {
|
|
75
|
+
selectedMethodIds[selectedKey.value] = value
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
const selectedMethod = computed(
|
|
80
|
+
() => selectedService.value?.methods.find((method) => method.id === selectedMethodId.value) ?? null,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// gRPC-Web only supports unary and server-streaming calls (missing method types are treated as
|
|
84
|
+
// unary). Client-/bidi-streaming methods render as documentation with a "not invokable" badge.
|
|
85
|
+
const isInvokable = (method: GrpcMethodModel) =>
|
|
86
|
+
method.methodType === undefined || method.methodType === 'unary' || method.methodType === 'serverStreaming'
|
|
87
|
+
|
|
88
|
+
const effectiveBaseUrl = computed(() =>
|
|
89
|
+
(baseUrlOverride.value || selectedService.value?.baseUrl || '').replace(/\/+$/, ''),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const methodUrl = computed(() => {
|
|
93
|
+
const service = selectedService.value
|
|
94
|
+
const method = selectedMethod.value
|
|
95
|
+
if (!service || !method) {
|
|
96
|
+
return ''
|
|
97
|
+
}
|
|
98
|
+
return `${effectiveBaseUrl.value}/${service.service}/${method.method}`
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
function addLog(dir: LogEntry['dir'], text: string) {
|
|
102
|
+
log.unshift({ time: new Date().toLocaleTimeString(), dir, text })
|
|
103
|
+
if (log.length > 200) {
|
|
104
|
+
log.pop()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function methodTypeLabel(method: GrpcMethodModel): string {
|
|
109
|
+
switch (method.methodType) {
|
|
110
|
+
case 'serverStreaming':
|
|
111
|
+
return 'SERVER STREAM'
|
|
112
|
+
case 'clientStreaming':
|
|
113
|
+
return 'CLIENT STREAM'
|
|
114
|
+
case 'bidirectionalStreaming':
|
|
115
|
+
return 'BIDI STREAM'
|
|
116
|
+
default:
|
|
117
|
+
return 'UNARY'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Parse the request editor contents into a proto3-JSON object (empty editor → empty message). */
|
|
122
|
+
function parseRequest(raw: string): JsonValue {
|
|
123
|
+
const text = (raw ?? '').trim()
|
|
124
|
+
if (!text) {
|
|
125
|
+
return {}
|
|
126
|
+
}
|
|
127
|
+
return JSON.parse(text) as JsonValue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Parse the metadata editor contents into flat string headers. */
|
|
131
|
+
function parseMetadata(raw: string): Record<string, string> {
|
|
132
|
+
const text = (raw ?? '').trim()
|
|
133
|
+
if (!text) {
|
|
134
|
+
return {}
|
|
135
|
+
}
|
|
136
|
+
const parsed = JSON.parse(text)
|
|
137
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
138
|
+
throw new Error('metadata must be a JSON object of header names to string values')
|
|
139
|
+
}
|
|
140
|
+
const headers: Record<string, string> = {}
|
|
141
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
142
|
+
headers[key] = String(value)
|
|
143
|
+
}
|
|
144
|
+
return headers
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function testRequest(method: GrpcMethodModel) {
|
|
148
|
+
const service = selectedService.value
|
|
149
|
+
if (!service || invoking.value) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const key = methodKey(method)
|
|
153
|
+
|
|
154
|
+
let request: JsonValue
|
|
155
|
+
try {
|
|
156
|
+
request = parseRequest(requestJson[key])
|
|
157
|
+
} catch (error: any) {
|
|
158
|
+
results[key] = { ok: false, text: `Invalid JSON request: ${error?.message ?? error}` }
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
let headers: Record<string, string>
|
|
162
|
+
try {
|
|
163
|
+
headers = parseMetadata(metadataJson[key])
|
|
164
|
+
} catch (error: any) {
|
|
165
|
+
results[key] = { ok: false, text: `Invalid metadata: ${error?.message ?? error}` }
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Resolve Scalar's current auth state → no-op on stock Scalar or when no scheme is selected.
|
|
170
|
+
// Explicit metadata entered in the editor wins over auth-derived headers of the same name.
|
|
171
|
+
const authResult = resolveGrpcAuth(service.documentName, service.securitySchemes, getAuthState())
|
|
172
|
+
for (const warn of authResult.warnings) {
|
|
173
|
+
addLog('sys', `Auth warning: ${warn}`)
|
|
174
|
+
}
|
|
175
|
+
headers = { ...authResult.headers, ...headers }
|
|
176
|
+
|
|
177
|
+
const hasSchemes = service.securitySchemes && Object.keys(service.securitySchemes).length > 0
|
|
178
|
+
if (hasSchemes && Object.keys(authResult.headers).length === 0) {
|
|
179
|
+
addLog(
|
|
180
|
+
'sys',
|
|
181
|
+
`Auth: no credentials resolved for document "${service.documentName}" — select a security ` +
|
|
182
|
+
`scheme in the Authentication panel and enter your credentials.`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
invoking.value = true
|
|
187
|
+
addLog('out', `${service.service}/${method.method} ${JSON.stringify(request)}`)
|
|
188
|
+
try {
|
|
189
|
+
const registry = await loadDescriptorRegistry(descriptorsUrlFor(effectiveBaseUrl.value))
|
|
190
|
+
const callOptions = {
|
|
191
|
+
baseUrl: effectiveBaseUrl.value,
|
|
192
|
+
headers,
|
|
193
|
+
timeoutMs: method.deadlineSeconds ? method.deadlineSeconds * 1000 : undefined,
|
|
194
|
+
}
|
|
195
|
+
if (method.methodType === 'serverStreaming') {
|
|
196
|
+
const items: JsonValue[] = []
|
|
197
|
+
await invokeServerStreaming(registry, service.service, method.method, request, callOptions, (message) => {
|
|
198
|
+
items.push(message)
|
|
199
|
+
addLog('in', `← ${JSON.stringify(message)}`)
|
|
200
|
+
})
|
|
201
|
+
results[key] = {
|
|
202
|
+
ok: true,
|
|
203
|
+
text: `Stream completed: ${items.length} message(s).\n${JSON.stringify(items, null, 2)}`,
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
const response = await invokeUnary(registry, service.service, method.method, request, callOptions)
|
|
207
|
+
results[key] = { ok: true, text: JSON.stringify(response, null, 2) }
|
|
208
|
+
addLog('in', `← ${JSON.stringify(response)}`)
|
|
209
|
+
}
|
|
210
|
+
} catch (error: any) {
|
|
211
|
+
results[key] = { ok: false, text: `Error: ${error?.message ?? error}` }
|
|
212
|
+
addLog('sys', `Invoke failed: ${error?.message ?? error}`)
|
|
213
|
+
} finally {
|
|
214
|
+
invoking.value = false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The example string for a given message of a method (falls back to an empty object). */
|
|
219
|
+
function exampleFor(method: GrpcMethodModel | null, index: number): string {
|
|
220
|
+
return method?.messages?.[index]?.example ?? '{}'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Select a message and (re)fill the request editor with its generated example. */
|
|
224
|
+
function applyExample(method: GrpcMethodModel, index: number) {
|
|
225
|
+
const key = methodKey(method)
|
|
226
|
+
selectedMessageIndex[key] = index
|
|
227
|
+
requestJson[key] = exampleFor(method, index)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Prefill a method's editor with its first example, but only if it is still untouched. */
|
|
231
|
+
function ensurePrefilled(method: GrpcMethodModel) {
|
|
232
|
+
const key = methodKey(method)
|
|
233
|
+
if (requestJson[key] === undefined) {
|
|
234
|
+
applyExample(method, selectedMessageIndex[key] ?? 0)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Keep the selected service within the visible (document-scoped) set. Fires when the document
|
|
239
|
+
// changes (picker or active-document detection) and when services finish loading.
|
|
240
|
+
watch(
|
|
241
|
+
filteredServices,
|
|
242
|
+
(list) => {
|
|
243
|
+
if (!list.some((service) => serviceKey(service) === selectedKey.value)) {
|
|
244
|
+
selectedKey.value = list[0] ? serviceKey(list[0]) : null
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{ immediate: true },
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
watch(selectedService, (service) => {
|
|
251
|
+
baseUrlOverride.value = service?.baseUrl ?? ''
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// When the available methods change (e.g. a different service), select the first one and prefill it.
|
|
255
|
+
watch(
|
|
256
|
+
() => selectedService.value?.methods ?? [],
|
|
257
|
+
(methods) => {
|
|
258
|
+
if (!methods.some((method) => method.id === selectedMethodId.value)) {
|
|
259
|
+
selectedMethodId.value = methods[0]?.id ?? null
|
|
260
|
+
}
|
|
261
|
+
methods.forEach(ensurePrefilled)
|
|
262
|
+
},
|
|
263
|
+
{ immediate: true },
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
// Selecting a different method prefills its editor on first visit.
|
|
267
|
+
watch(selectedMethod, (method) => {
|
|
268
|
+
if (method) {
|
|
269
|
+
ensurePrefilled(method)
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Discover Scalar's active document from the plugin-view wrapper this console is rendered inside.
|
|
275
|
+
* Scalar renders each `content.end` plugin view under a `<div id="{documentSlug}/plugin-view/…">`
|
|
276
|
+
* scoped to the active document, so the slug is the id prefix. Returns null when the console is not
|
|
277
|
+
* inside such a wrapper (stock Scalar / standalone), leaving the multi-document picker in place.
|
|
278
|
+
*/
|
|
279
|
+
function detectActiveDocumentSlug(): string | null {
|
|
280
|
+
const wrapper = rootEl.value?.closest('[id*="/plugin-view/"]')
|
|
281
|
+
const slug = wrapper?.id.split('/plugin-view/')[0]
|
|
282
|
+
return slug || null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function init() {
|
|
286
|
+
loading.value = true
|
|
287
|
+
services.value = await loadGrpcServices(resolveDocuments(props.options, props.documents))
|
|
288
|
+
// Seed a default document for the picker; the active-document scope (when detected) overrides it.
|
|
289
|
+
if (services.value.length > 0 && !selectedDocumentName.value) {
|
|
290
|
+
selectedDocumentName.value = services.value[0].documentName
|
|
291
|
+
}
|
|
292
|
+
loading.value = false
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
void init()
|
|
296
|
+
onMounted(() => {
|
|
297
|
+
activeDocumentSlug.value = detectActiveDocumentSlug()
|
|
298
|
+
})
|
|
299
|
+
</script>
|
|
300
|
+
|
|
301
|
+
<template>
|
|
302
|
+
<section ref="rootEl" class="bgr">
|
|
303
|
+
<header class="bgr__head">
|
|
304
|
+
<h2 class="bgr__title">gRPC</h2>
|
|
305
|
+
<span class="bgr__pill">gRPC-Web</span>
|
|
306
|
+
</header>
|
|
307
|
+
|
|
308
|
+
<p v-if="loading" class="bgr__muted">Loading gRPC services…</p>
|
|
309
|
+
<p v-else-if="filteredServices.length === 0" class="bgr__muted">
|
|
310
|
+
No gRPC services found in {{ activeDocumentSlug ? 'this document' : 'the AsyncAPI document(s)' }}.
|
|
311
|
+
</p>
|
|
312
|
+
|
|
313
|
+
<template v-else>
|
|
314
|
+
<!-- Target bar -->
|
|
315
|
+
<div class="bgr__card bgr__conn">
|
|
316
|
+
<div class="bgr__conn-row">
|
|
317
|
+
<label v-if="showDocumentPicker" class="bgr__field">
|
|
318
|
+
<span>Document</span>
|
|
319
|
+
<select v-model="selectedDocumentName">
|
|
320
|
+
<option v-for="doc in uniqueDocuments" :key="doc" :value="doc">{{ doc }}</option>
|
|
321
|
+
</select>
|
|
322
|
+
</label>
|
|
323
|
+
<label class="bgr__field">
|
|
324
|
+
<span>Service</span>
|
|
325
|
+
<select v-model="selectedKey">
|
|
326
|
+
<option v-for="service in filteredServices" :key="serviceKey(service)" :value="serviceKey(service)">
|
|
327
|
+
{{ service.service }}
|
|
328
|
+
</option>
|
|
329
|
+
</select>
|
|
330
|
+
</label>
|
|
331
|
+
<label class="bgr__field bgr__field--grow">
|
|
332
|
+
<span>Base URL</span>
|
|
333
|
+
<input v-model="baseUrlOverride" type="text" spellcheck="false" />
|
|
334
|
+
</label>
|
|
335
|
+
</div>
|
|
336
|
+
<div v-if="selectedService?.protoFile" class="bgr__conn-row bgr__conn-row--foot">
|
|
337
|
+
<span class="bgr__hint">{{ selectedService.protoFile }}</span>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<!-- Two columns on wide screens (method invocation | message log); stacked on narrow. -->
|
|
342
|
+
<div class="bgr__grid">
|
|
343
|
+
<!-- Left column: pick a method, edit the prefilled request, invoke over gRPC-Web. -->
|
|
344
|
+
<div class="bgr__col">
|
|
345
|
+
<h3 class="bgr__section">Methods</h3>
|
|
346
|
+
<p v-if="!selectedService || selectedService.methods.length === 0" class="bgr__muted">
|
|
347
|
+
No RPC methods declared for this service.
|
|
348
|
+
</p>
|
|
349
|
+
<article v-else class="bgr__card bgr__op">
|
|
350
|
+
<div class="bgr__conn-row">
|
|
351
|
+
<label class="bgr__field bgr__field--grow">
|
|
352
|
+
<span>Method</span>
|
|
353
|
+
<select v-model="selectedMethodId">
|
|
354
|
+
<option v-for="method in selectedService.methods" :key="method.id" :value="method.id">
|
|
355
|
+
{{ method.method }} · {{ methodTypeLabel(method) }}
|
|
356
|
+
</option>
|
|
357
|
+
</select>
|
|
358
|
+
</label>
|
|
359
|
+
<label v-if="selectedMethod && selectedMethod.messages.length > 1" class="bgr__field bgr__field--grow">
|
|
360
|
+
<span>Message</span>
|
|
361
|
+
<select
|
|
362
|
+
:value="selectedMessageIndex[methodKey(selectedMethod)] ?? 0"
|
|
363
|
+
@change="applyExample(selectedMethod, Number(($event.target as HTMLSelectElement).value))"
|
|
364
|
+
>
|
|
365
|
+
<option v-for="(message, index) in selectedMethod.messages" :key="message.name" :value="index">
|
|
366
|
+
{{ message.title || message.name }}
|
|
367
|
+
</option>
|
|
368
|
+
</select>
|
|
369
|
+
</label>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<template v-if="selectedMethod">
|
|
373
|
+
<div class="bgr__op-head bgr__op-head--spaced">
|
|
374
|
+
<span class="bgr__method" :data-kind="selectedMethod.methodType ?? 'unary'">
|
|
375
|
+
{{ methodTypeLabel(selectedMethod) }}
|
|
376
|
+
</span>
|
|
377
|
+
<span class="bgr__op-target">{{ selectedMethod.method }}</span>
|
|
378
|
+
<span v-if="!isInvokable(selectedMethod)" class="bgr__badge-warn">not invokable from the browser</span>
|
|
379
|
+
</div>
|
|
380
|
+
<p v-if="selectedMethod.summary" class="bgr__muted bgr__op-summary">{{ selectedMethod.summary }}</p>
|
|
381
|
+
<p class="bgr__hint bgr__op-summary">
|
|
382
|
+
{{ selectedMethod.requestType ?? '?' }} → {{ selectedMethod.responseType ?? '?'
|
|
383
|
+
}}<template v-if="selectedMethod.deadlineSeconds"> · deadline {{ selectedMethod.deadlineSeconds }}s</template>
|
|
384
|
+
<template v-if="selectedMethod.idempotencyLevel"> · {{ selectedMethod.idempotencyLevel }}</template>
|
|
385
|
+
</p>
|
|
386
|
+
|
|
387
|
+
<template v-if="isInvokable(selectedMethod)">
|
|
388
|
+
<div class="bgr__code-head">
|
|
389
|
+
<span class="bgr__hint">Request message (JSON)</span>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
class="bgr__link"
|
|
393
|
+
:disabled="selectedMethod.messages.length === 0"
|
|
394
|
+
@click="applyExample(selectedMethod, selectedMessageIndex[methodKey(selectedMethod)] ?? 0)"
|
|
395
|
+
>
|
|
396
|
+
↺ Reset to example
|
|
397
|
+
</button>
|
|
398
|
+
</div>
|
|
399
|
+
<textarea
|
|
400
|
+
v-model="requestJson[methodKey(selectedMethod)]"
|
|
401
|
+
class="bgr__code"
|
|
402
|
+
rows="6"
|
|
403
|
+
spellcheck="false"
|
|
404
|
+
placeholder='Request message as JSON, e.g. { "name": "world" }'
|
|
405
|
+
/>
|
|
406
|
+
<div class="bgr__code-head">
|
|
407
|
+
<span class="bgr__hint">Metadata (JSON object of headers, optional)</span>
|
|
408
|
+
</div>
|
|
409
|
+
<textarea
|
|
410
|
+
v-model="metadataJson[methodKey(selectedMethod)]"
|
|
411
|
+
class="bgr__code"
|
|
412
|
+
rows="2"
|
|
413
|
+
spellcheck="false"
|
|
414
|
+
placeholder='{ "x-correlation-id": "1234" }'
|
|
415
|
+
/>
|
|
416
|
+
<div class="bgr__op-foot">
|
|
417
|
+
<span class="bgr__hint">{{ methodUrl }}</span>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
class="bgr__btn bgr__btn--primary"
|
|
421
|
+
:disabled="invoking"
|
|
422
|
+
@click="testRequest(selectedMethod)"
|
|
423
|
+
>
|
|
424
|
+
▶ Test Request
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
<pre
|
|
428
|
+
v-if="results[methodKey(selectedMethod)]"
|
|
429
|
+
class="bgr__result"
|
|
430
|
+
:data-ok="results[methodKey(selectedMethod)].ok"
|
|
431
|
+
>{{ results[methodKey(selectedMethod)].text }}</pre>
|
|
432
|
+
</template>
|
|
433
|
+
<p v-else class="bgr__muted bgr__op-summary">
|
|
434
|
+
gRPC-Web supports unary and server-streaming calls only; client- and bidirectional-streaming
|
|
435
|
+
methods are shown for documentation. The example request below is generated from the AsyncAPI
|
|
436
|
+
payload schema.
|
|
437
|
+
</p>
|
|
438
|
+
<pre
|
|
439
|
+
v-if="!isInvokable(selectedMethod) && selectedMethod.messages.length > 0"
|
|
440
|
+
class="bgr__result bgr__result--sample"
|
|
441
|
+
>{{ selectedMethod.messages[0].example }}</pre>
|
|
442
|
+
</template>
|
|
443
|
+
</article>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<!-- Right column: the live message log. -->
|
|
447
|
+
<div class="bgr__col">
|
|
448
|
+
<h3 class="bgr__section">Messages</h3>
|
|
449
|
+
<ul class="bgr__log">
|
|
450
|
+
<li v-for="(entry, index) in log" :key="index" :class="`bgr__log-${entry.dir}`">
|
|
451
|
+
<span class="bgr__time">{{ entry.time }}</span>
|
|
452
|
+
<span class="bgr__badge">{{ entry.dir }}</span>
|
|
453
|
+
<span>{{ entry.text }}</span>
|
|
454
|
+
</li>
|
|
455
|
+
<li v-if="log.length === 0" class="bgr__muted">No messages yet.</li>
|
|
456
|
+
</ul>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</template>
|
|
460
|
+
</section>
|
|
461
|
+
</template>
|
|
462
|
+
|
|
463
|
+
<style scoped>
|
|
464
|
+
.bgr {
|
|
465
|
+
font-family: var(--scalar-font, system-ui, sans-serif);
|
|
466
|
+
color: var(--scalar-color-1, inherit);
|
|
467
|
+
font-size: var(--scalar-font-size-3, 0.9rem);
|
|
468
|
+
/* Render as its own Scalar-style section: a clear top divider, generous breathing room above,
|
|
469
|
+
and horizontal insets so it lines up with the operations/models instead of sitting flush
|
|
470
|
+
against the viewport edge. `--bgr-content-padding` lets hosts fine-tune the inset. */
|
|
471
|
+
padding: 2.5rem var(--bgr-content-padding, var(--scalar-content-padding, 24px)) 3rem;
|
|
472
|
+
margin-top: 2rem;
|
|
473
|
+
border-top: 1px solid var(--scalar-border-color, #e3e3e3);
|
|
474
|
+
max-width: var(--bgr-content-max-width, none);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.bgr__head {
|
|
478
|
+
display: flex;
|
|
479
|
+
align-items: center;
|
|
480
|
+
gap: 0.75rem;
|
|
481
|
+
margin-bottom: 1.25rem;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* Two-column layout on wide screens (method | log); single column when narrow.
|
|
485
|
+
The target bar stays full width above this grid. */
|
|
486
|
+
.bgr__grid {
|
|
487
|
+
display: grid;
|
|
488
|
+
grid-template-columns: 1fr;
|
|
489
|
+
gap: 0 2rem;
|
|
490
|
+
align-items: start;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.bgr__col {
|
|
494
|
+
min-width: 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@media (min-width: 60rem) {
|
|
498
|
+
.bgr__grid {
|
|
499
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.bgr__title {
|
|
504
|
+
margin: 0;
|
|
505
|
+
font-size: 1.25rem;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.bgr__pill {
|
|
509
|
+
font-size: 0.7rem;
|
|
510
|
+
text-transform: uppercase;
|
|
511
|
+
letter-spacing: 0.03em;
|
|
512
|
+
padding: 0.15rem 0.5rem;
|
|
513
|
+
border-radius: 999px;
|
|
514
|
+
background: var(--scalar-background-2, #eee);
|
|
515
|
+
color: var(--scalar-color-2, #555);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
.bgr__card {
|
|
519
|
+
border: 1px solid var(--scalar-border-color, #e3e3e3);
|
|
520
|
+
border-radius: var(--scalar-radius, 8px);
|
|
521
|
+
background: var(--scalar-background-1, transparent);
|
|
522
|
+
padding: 0.85rem;
|
|
523
|
+
margin-bottom: 0.85rem;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.bgr__conn-row {
|
|
527
|
+
display: flex;
|
|
528
|
+
flex-wrap: wrap;
|
|
529
|
+
gap: 0.75rem;
|
|
530
|
+
align-items: flex-end;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.bgr__conn-row--foot {
|
|
534
|
+
margin-top: 0.75rem;
|
|
535
|
+
align-items: center;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.bgr__field {
|
|
539
|
+
display: flex;
|
|
540
|
+
flex-direction: column;
|
|
541
|
+
gap: 0.25rem;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.bgr__field > span {
|
|
545
|
+
font-size: 0.72rem;
|
|
546
|
+
text-transform: uppercase;
|
|
547
|
+
letter-spacing: 0.03em;
|
|
548
|
+
color: var(--scalar-color-3, #777);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.bgr__field--grow {
|
|
552
|
+
flex: 1;
|
|
553
|
+
min-width: 12rem;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.bgr__field input,
|
|
557
|
+
.bgr__field select {
|
|
558
|
+
padding: 0.35rem 0.5rem;
|
|
559
|
+
border: 1px solid var(--scalar-border-color, #ddd);
|
|
560
|
+
border-radius: var(--scalar-radius, 6px);
|
|
561
|
+
background: var(--scalar-background-2, #fff);
|
|
562
|
+
color: inherit;
|
|
563
|
+
font: inherit;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.bgr__section {
|
|
567
|
+
margin: 1.25rem 0 0.5rem;
|
|
568
|
+
font-size: 0.95rem;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.bgr__op-head {
|
|
572
|
+
display: flex;
|
|
573
|
+
align-items: center;
|
|
574
|
+
gap: 0.6rem;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.bgr__op-head--spaced {
|
|
578
|
+
margin-top: 0.85rem;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.bgr__code-head {
|
|
582
|
+
display: flex;
|
|
583
|
+
align-items: center;
|
|
584
|
+
justify-content: space-between;
|
|
585
|
+
margin-top: 0.6rem;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
.bgr__link {
|
|
589
|
+
font: inherit;
|
|
590
|
+
font-size: 0.72rem;
|
|
591
|
+
background: none;
|
|
592
|
+
border: 0;
|
|
593
|
+
padding: 0;
|
|
594
|
+
cursor: pointer;
|
|
595
|
+
color: var(--scalar-color-blue, #2563eb);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.bgr__link:disabled {
|
|
599
|
+
opacity: 0.5;
|
|
600
|
+
cursor: not-allowed;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.bgr__result--sample {
|
|
604
|
+
border-left-color: var(--scalar-color-3, #888);
|
|
605
|
+
margin-top: 0.6rem;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.bgr__method {
|
|
609
|
+
font-family: var(--scalar-font-code, monospace);
|
|
610
|
+
font-size: 0.7rem;
|
|
611
|
+
font-weight: 700;
|
|
612
|
+
letter-spacing: 0.03em;
|
|
613
|
+
padding: 0.15rem 0.45rem;
|
|
614
|
+
border-radius: var(--scalar-radius, 5px);
|
|
615
|
+
background: color-mix(in srgb, var(--scalar-color-blue, #2563eb) 16%, transparent);
|
|
616
|
+
color: var(--scalar-color-blue, #2563eb);
|
|
617
|
+
white-space: nowrap;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.bgr__method[data-kind='serverStreaming'] {
|
|
621
|
+
background: color-mix(in srgb, var(--scalar-color-green, #16a34a) 16%, transparent);
|
|
622
|
+
color: var(--scalar-color-green, #16a34a);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.bgr__method[data-kind='clientStreaming'],
|
|
626
|
+
.bgr__method[data-kind='bidirectionalStreaming'] {
|
|
627
|
+
background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 16%, transparent);
|
|
628
|
+
color: var(--scalar-color-orange, #d97706);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.bgr__badge-warn {
|
|
632
|
+
font-size: 0.68rem;
|
|
633
|
+
text-transform: uppercase;
|
|
634
|
+
letter-spacing: 0.03em;
|
|
635
|
+
padding: 0.12rem 0.4rem;
|
|
636
|
+
border-radius: 999px;
|
|
637
|
+
background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 16%, transparent);
|
|
638
|
+
color: var(--scalar-color-orange, #d97706);
|
|
639
|
+
white-space: nowrap;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.bgr__op-target {
|
|
643
|
+
font-family: var(--scalar-font-code, monospace);
|
|
644
|
+
font-weight: 600;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.bgr__op-summary {
|
|
648
|
+
margin: 0.4rem 0 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.bgr__code {
|
|
652
|
+
width: 100%;
|
|
653
|
+
margin-top: 0.6rem;
|
|
654
|
+
padding: 0.6rem;
|
|
655
|
+
border: 1px solid var(--scalar-border-color, #ddd);
|
|
656
|
+
border-radius: var(--scalar-radius, 6px);
|
|
657
|
+
background: var(--scalar-background-2, #1e1e1e);
|
|
658
|
+
color: inherit;
|
|
659
|
+
font-family: var(--scalar-font-code, monospace);
|
|
660
|
+
font-size: 0.82rem;
|
|
661
|
+
resize: vertical;
|
|
662
|
+
box-sizing: border-box;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.bgr__op-foot {
|
|
666
|
+
display: flex;
|
|
667
|
+
align-items: center;
|
|
668
|
+
justify-content: space-between;
|
|
669
|
+
margin-top: 0.6rem;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.bgr__hint {
|
|
673
|
+
font-size: 0.72rem;
|
|
674
|
+
color: var(--scalar-color-3, #888);
|
|
675
|
+
overflow-wrap: anywhere;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.bgr__btn {
|
|
679
|
+
font: inherit;
|
|
680
|
+
font-weight: 600;
|
|
681
|
+
padding: 0.4rem 0.8rem;
|
|
682
|
+
border-radius: var(--scalar-radius, 6px);
|
|
683
|
+
border: 1px solid var(--scalar-border-color, #ccc);
|
|
684
|
+
background: var(--scalar-background-2, #f4f4f4);
|
|
685
|
+
color: inherit;
|
|
686
|
+
cursor: pointer;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.bgr__btn--primary {
|
|
690
|
+
background: var(--scalar-button-1, #111);
|
|
691
|
+
color: var(--scalar-button-1-color, #fff);
|
|
692
|
+
border-color: transparent;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.bgr__btn:disabled {
|
|
696
|
+
opacity: 0.5;
|
|
697
|
+
cursor: not-allowed;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.bgr__result {
|
|
701
|
+
margin: 0.6rem 0 0;
|
|
702
|
+
padding: 0.6rem;
|
|
703
|
+
border-radius: var(--scalar-radius, 6px);
|
|
704
|
+
background: var(--scalar-background-2, #f6f6f6);
|
|
705
|
+
border-left: 3px solid var(--scalar-color-green, #16a34a);
|
|
706
|
+
font-family: var(--scalar-font-code, monospace);
|
|
707
|
+
font-size: 0.8rem;
|
|
708
|
+
white-space: pre-wrap;
|
|
709
|
+
overflow-wrap: anywhere;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.bgr__result[data-ok='false'] {
|
|
713
|
+
border-left-color: var(--scalar-color-red, #dc2626);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.bgr__log {
|
|
717
|
+
list-style: none;
|
|
718
|
+
margin: 0;
|
|
719
|
+
padding: 0.5rem;
|
|
720
|
+
max-height: 16rem;
|
|
721
|
+
overflow: auto;
|
|
722
|
+
border: 1px solid var(--scalar-border-color, #e3e3e3);
|
|
723
|
+
border-radius: var(--scalar-radius, 8px);
|
|
724
|
+
background: var(--scalar-background-1, transparent);
|
|
725
|
+
font-family: var(--scalar-font-code, monospace);
|
|
726
|
+
font-size: 0.78rem;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.bgr__log li {
|
|
730
|
+
display: flex;
|
|
731
|
+
gap: 0.5rem;
|
|
732
|
+
padding: 0.12rem 0;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.bgr__time {
|
|
736
|
+
opacity: 0.55;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.bgr__badge {
|
|
740
|
+
text-transform: uppercase;
|
|
741
|
+
font-size: 0.62rem;
|
|
742
|
+
align-self: center;
|
|
743
|
+
border-radius: 3px;
|
|
744
|
+
padding: 0 0.25rem;
|
|
745
|
+
background: var(--scalar-background-2, #eee);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.bgr__log-in .bgr__badge {
|
|
749
|
+
background: color-mix(in srgb, var(--scalar-color-blue, #2563eb) 20%, transparent);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.bgr__log-out .bgr__badge {
|
|
753
|
+
background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 20%, transparent);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.bgr__muted {
|
|
757
|
+
color: var(--scalar-color-3, #888);
|
|
758
|
+
}
|
|
759
|
+
</style>
|