Blocks
API Request Console Block
Developer UIA working, server-proxied API console for composing safe calls, managing revisioned templates, and inspecting immutable request evidence.
Developer Experience / API Tools
API request console
A Postman- and Insomnia-inspired application section with responsive library, request, response, and environment views backed by repository-local API routes.
Built with
DomAlertVisualDomBadgeVisualDomButtonComponentsDomCheckboxFormsDomCodeInputFormsDomDialogComponentsDomEmptyStateVisualDomJsonViewerDevDomRangeInputFormsDomSelectFormsDomSkeletonVisualDomStatusPillVisualDomTabsComponentsDomTagComboboxFormsDomTextareaInputFormsDomTextInputFormsDomToggleButtonGroupForms<script setup>
import { computed, onMounted, ref } from 'vue';
import {
DomAlert,
DomBadge,
DomButton,
DomCheckbox,
DomCodeInput,
DomDialog,
DomEmptyState,
DomJsonViewer,
DomRangeInput,
DomSelect,
DomSkeleton,
DomStatusPill,
DomTabs,
DomTagCombobox,
DomTextareaInput,
DomTextInput,
DomToggleButtonGroup,
} from '@getdom/studio/vue';
const mobileViews = [
{ key: 'library', label: 'Library' },
{ key: 'request', label: 'Request' },
{ key: 'response', label: 'Response' },
{ key: 'context', label: 'Context' },
];
const libraryModes = [
{ label: 'Templates', value: 'templates' },
{ label: 'History', value: 'history' },
];
const requestTabs = [
{ key: 'body', label: 'Body' },
{ key: 'authorization', label: 'Authorization' },
{ key: 'headers', label: 'Headers' },
];
const responseTabs = [
{ key: 'response-body', label: 'Body' },
{ key: 'response-headers', label: 'Headers' },
{ key: 'timeline', label: 'Timeline' },
];
const loading = ref(true);
const busy = ref(false);
const bootstrap = ref(null);
const selectedTemplate = ref(null);
const selectedRun = ref(null);
const selectedTemplateId = ref('');
const selectedRunId = ref('');
const activeView = ref('request');
const libraryMode = ref('templates');
const requestTab = ref('body');
const responseTab = ref('response-body');
const searchQuery = ref('');
const errorMessage = ref('');
const successMessage = ref('');
const fieldErrors = ref({});
const saveDialogOpen = ref(false);
const productionDialogOpen = ref(false);
const resetDialogOpen = ref(false);
const productionAcknowledged = ref(false);
const saveDraft = ref({ name: '', description: '', collectionId: 'billing', visibility: 'team' });
const draft = ref(createEmptyDraft());
const templates = computed(() => bootstrap.value?.templates || []);
const runs = computed(() => bootstrap.value?.runs || []);
const selectedEnvironment = computed(() => bootstrap.value?.options.environments.find((item) => item.value === draft.value.environmentId) || null);
const resolvedUrl = computed(() => `${selectedEnvironment.value?.baseUrl || ''}${draft.value.path || '/'}`);
const isProductionWrite = computed(() => draft.value.environmentId === 'production' && draft.value.method !== 'GET');
const filteredTemplates = computed(() => filterItems(templates.value, searchQuery.value, ['name', 'description', 'method', 'path']));
const filteredRuns = computed(() => filterItems(runs.value, searchQuery.value, ['requestId', 'method', 'path', 'statusLabel']));
const isTemplateDirty = computed(() => {
if (!selectedTemplate.value) return false;
return JSON.stringify(templateRequestFields(selectedTemplate.value)) !== JSON.stringify(templateRequestFields(draft.value));
});
const responseSummary = computed(() => selectedRun.value ? `${selectedRun.value.statusLabel} · ${selectedRun.value.latencyMs}ms` : 'No response yet');
const rateLimitRemaining = computed(() => selectedRun.value?.response.headers['x-ratelimit-remaining'] || '—');
/**
* Creates the blank request draft used before the bootstrap response arrives.
*
* @returns {Record<string, unknown>} Empty request draft.
*/
function createEmptyDraft() {
return {
templateId: null,
method: 'GET',
environmentId: 'staging',
path: '/v1/customers/{{customer_id}}',
authMode: 'workspace-key',
scopes: ['customers:read'],
headers: '{\n\t"Accept": "application/json"\n}',
body: '{}',
timeoutMs: 3000,
};
}
/**
* Loads the server-owned console workspace.
*
* @returns {Promise<void>} Resolves after workspace state is ready.
*/
async function loadWorkspace() {
loading.value = true;
clearMessages();
try {
const result = await requestJson('/api/block-demos/api-request-console/bootstrap');
bootstrap.value = result;
applyTemplate(result.template);
selectedRun.value = result.run;
selectedRunId.value = result.run?.id || '';
} catch (error) {
errorMessage.value = error.message;
} finally {
loading.value = false;
}
}
/**
* Applies a saved request to the composer.
*
* @param {Record<string, unknown>} template Saved template.
* @returns {void}
*/
function applyTemplate(template) {
if (!template) return;
selectedTemplate.value = structuredClone(template);
selectedTemplateId.value = template.id;
draft.value = { ...templateRequestFields(template), templateId: template.id };
fieldErrors.value = {};
activeView.value = 'request';
}
/**
* Loads an immutable run into the response inspector.
*
* @param {Record<string, unknown>} run Request run.
* @returns {void}
*/
function inspectRun(run) {
selectedRun.value = structuredClone(run);
selectedRunId.value = run.id;
responseTab.value = 'response-body';
activeView.value = 'response';
}
/**
* Rehydrates the composer from a prior run for safe replay.
*
* @returns {void}
*/
function loadRunRequest() {
if (!selectedRun.value) return;
draft.value = {
templateId: selectedRun.value.templateId,
method: selectedRun.value.method,
environmentId: selectedRun.value.environmentId,
path: selectedRun.value.path,
authMode: selectedRun.value.authMode,
scopes: [...selectedRun.value.scopes],
headers: JSON.stringify(selectedRun.value.headers || {}, null, 2),
body: JSON.stringify(selectedRun.value.body || {}, null, 2),
timeoutMs: selectedRun.value.timeoutMs,
};
activeView.value = 'request';
successMessage.value = 'Prior request loaded. Review the environment and authorization before sending.';
}
/**
* Dispatches immediately or opens the protected production confirmation.
*
* @returns {Promise<void>} Resolves after send routing is complete.
*/
async function requestSend() {
if (isProductionWrite.value && !productionAcknowledged.value) {
productionDialogOpen.value = true;
return;
}
await sendRequest();
}
/**
* Sends the draft through the server-owned proxy and records the run.
*
* @returns {Promise<void>} Resolves after response evidence is stored.
*/
async function sendRequest() {
busy.value = true;
clearMessages();
try {
const result = await requestJson('/api/block-demos/api-request-console/runs', {
method: 'POST',
body: JSON.stringify({ ...draft.value, productionAcknowledged: productionAcknowledged.value }),
});
bootstrap.value.runs = result.runs;
bootstrap.value.templates = result.templates;
selectedRun.value = result.run;
selectedRunId.value = result.run.id;
productionDialogOpen.value = false;
productionAcknowledged.value = false;
successMessage.value = result.message;
responseTab.value = 'response-body';
activeView.value = 'response';
} catch (error) {
captureApiError(error);
} finally {
busy.value = false;
}
}
/**
* Saves request changes back to the selected template revision.
*
* @returns {Promise<void>} Resolves after template persistence.
*/
async function saveChanges() {
if (!selectedTemplate.value) return;
busy.value = true;
clearMessages();
try {
const payload = { ...selectedTemplate.value, ...draft.value, revision: selectedTemplate.value.revision };
const result = await requestJson(`/api/block-demos/api-request-console/templates/${selectedTemplate.value.id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
bootstrap.value.templates = result.templates;
applyTemplate(result.template);
successMessage.value = result.message;
} catch (error) {
if (error.data?.template) {
bootstrap.value.templates = error.data.templates;
applyTemplate(error.data.template);
}
captureApiError(error);
} finally {
busy.value = false;
}
}
/**
* Opens the save-as dialog with useful defaults.
*
* @returns {void}
*/
function openSaveDialog() {
saveDraft.value = {
name: selectedTemplate.value ? `${selectedTemplate.value.name} copy` : 'New API request',
description: selectedTemplate.value?.description || 'Reusable request for the developer platform team.',
collectionId: selectedTemplate.value?.collectionId || 'operations',
visibility: selectedTemplate.value?.visibility || 'team',
};
fieldErrors.value = {};
saveDialogOpen.value = true;
}
/**
* Creates a new saved template from the current composer state.
*
* @returns {Promise<void>} Resolves after template creation.
*/
async function createTemplate() {
busy.value = true;
clearMessages();
try {
const result = await requestJson('/api/block-demos/api-request-console/templates', {
method: 'POST',
body: JSON.stringify({ ...draft.value, ...saveDraft.value }),
});
bootstrap.value.templates = result.templates;
applyTemplate(result.template);
saveDialogOpen.value = false;
successMessage.value = result.message;
} catch (error) {
captureApiError(error);
} finally {
busy.value = false;
}
}
/**
* Restores the seeded workspace through the reset API.
*
* @returns {Promise<void>} Resolves after reset completes.
*/
async function resetWorkspace() {
busy.value = true;
clearMessages();
try {
const result = await requestJson('/api/block-demos/api-request-console/reset', { method: 'POST' });
bootstrap.value = result;
applyTemplate(result.template);
selectedRun.value = result.run;
selectedRunId.value = result.run?.id || '';
resetDialogOpen.value = false;
successMessage.value = result.message;
} catch (error) {
captureApiError(error);
} finally {
busy.value = false;
}
}
/**
* Extracts comparable request fields from a template or draft.
*
* @param {Record<string, unknown>} source Template or draft.
* @returns {Record<string, unknown>} Request fields.
*/
function templateRequestFields(source) {
return {
method: source.method,
environmentId: source.environmentId,
path: source.path,
authMode: source.authMode,
scopes: [...(source.scopes || [])],
headers: source.headers,
body: source.body,
timeoutMs: source.timeoutMs,
};
}
/**
* Filters list records across a set of searchable fields.
*
* @param {Array<Record<string, unknown>>} items Source records.
* @param {string} query Search query.
* @param {string[]} fields Searchable fields.
* @returns {Array<Record<string, unknown>>} Matching records.
*/
function filterItems(items, query, fields) {
const needle = String(query || '').trim().toLowerCase();
if (!needle) return items;
return items.filter((item) => fields.some((field) => String(item[field] || '').toLowerCase().includes(needle)));
}
/**
* Returns a registered option label.
*
* @param {Array<Record<string, unknown>>} options Option records.
* @param {string} value Selected value.
* @returns {string} Display label.
*/
function optionLabel(options, value) {
return options.find((option) => option.value === value)?.label || value;
}
/**
* Formats an ISO timestamp for compact workspace display.
*
* @param {string} value ISO timestamp.
* @returns {string} Local display time.
*/
function formatTime(value) {
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
/**
* Formats a JSON value for the code editor.
*
* @param {unknown} value JSON value.
* @returns {string} Pretty JSON source.
*/
function formatJson(value) {
return typeof value === 'string' ? value : JSON.stringify(value || {}, null, 2);
}
/**
* Clears transient messages and field errors.
*
* @returns {void}
*/
function clearMessages() {
errorMessage.value = '';
successMessage.value = '';
fieldErrors.value = {};
}
/**
* Maps structured API errors into visible page and field feedback.
*
* @param {Error & { data?: Record<string, unknown> }} error Request failure.
* @returns {void}
*/
function captureApiError(error) {
errorMessage.value = error.message;
fieldErrors.value = Object.fromEntries((error.data?.fields || []).map((item) => [item.field, [item.message]]));
}
/**
* Performs a JSON request and preserves structured API failure evidence.
*
* @param {string} url API URL.
* @param {RequestInit} options Fetch options.
* @returns {Promise<Record<string, unknown>>} Parsed response.
*/
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
});
const data = await response.json();
if (!response.ok || data.error) {
const error = new Error(data.message || `Request failed with ${response.status}.`);
error.data = data;
throw error;
}
return data;
}
onMounted(loadWorkspace);
</script>
<template>
<div class="h-dvh min-h-0 overflow-hidden bg-canvas text-canvas-fg">
<div v-if="loading" class="flex h-full flex-col">
<div class="flex h-16 items-center border-b border-border px-4"><DomSkeleton variant="text" :lines="1" width="30rem" /></div>
<div class="grid min-h-0 flex-1 lg:grid-cols-[17rem_minmax(0,1fr)_20rem]">
<div class="hidden border-r border-border p-4 lg:block"><DomSkeleton variant="text" :lines="14" /></div>
<div class="p-5"><DomSkeleton variant="text" :lines="18" /></div>
<div class="hidden border-l border-border p-4 lg:block"><DomSkeleton variant="text" :lines="12" /></div>
</div>
</div>
<div v-else-if="bootstrap" class="flex h-full min-h-0 flex-col">
<header class="shrink-0 border-b border-border bg-canvas">
<div class="flex min-h-16 items-center gap-3 px-3 py-2 sm:px-4">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<h1 class="truncate text-sm font-semibold sm:text-base">API request console</h1>
<DomStatusPill tone="success" size="sm">Proxy online</DomStatusPill>
<DomBadge v-if="isTemplateDirty" tone="warning" variant="outline">Unsaved</DomBadge>
</div>
<p class="truncate text-xs text-muted-fg">{{ bootstrap.workspace.name }} · {{ bootstrap.workspace.proxy }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<DomButton class="hidden sm:inline-flex" size="sm" variant="secondary" @click="openSaveDialog">Save as</DomButton>
<DomButton v-if="isTemplateDirty" class="hidden md:inline-flex" size="sm" variant="secondary" :loading="busy" @click="saveChanges">Save changes</DomButton>
<DomButton data-testid="send-request" size="sm" :loading="busy" @click="requestSend">Send request</DomButton>
</div>
</div>
</header>
<div v-if="errorMessage || successMessage" class="shrink-0 border-b border-border px-3 py-2 sm:px-4">
<DomAlert v-if="errorMessage" tone="danger" variant="soft" title="Request needs attention" :description="errorMessage" dismissible @dismiss="errorMessage = ''" />
<DomAlert v-else tone="success" variant="soft" title="Workspace updated" :description="successMessage" dismissible @dismiss="successMessage = ''" />
</div>
<DomTabs v-model="activeView" :tabs="mobileViews" variant="page" class="shrink-0 lg:hidden [&>div:last-child]:hidden" />
<div class="flex min-h-0 flex-1">
<aside class="min-h-0 w-full shrink-0 flex-col border-r border-border bg-secondary/10 lg:flex lg:w-68" :class="activeView === 'library' ? 'flex' : 'hidden'">
<div class="shrink-0 border-b border-border p-3">
<DomToggleButtonGroup v-model="libraryMode" :options="libraryModes" label="Library view" size="sm" chrome="none" />
<div class="mt-3"><DomTextInput v-model="searchQuery" label="Search library" placeholder="Path, method, request ID…" /></div>
</div>
<nav v-if="libraryMode === 'templates'" class="min-h-0 flex-1 divide-y divide-border overflow-y-auto" aria-label="Request templates">
<button v-for="template in filteredTemplates" :key="template.id" type="button" class="w-full border-l-2 px-4 py-4 text-left transition hover:bg-secondary/45 focus-visible:outline-2 focus-visible:outline-ring" :class="template.id === selectedTemplateId ? 'border-l-primary bg-secondary/55' : 'border-l-transparent'" @click="applyTemplate(template)">
<div class="flex items-start justify-between gap-3"><p class="min-w-0 truncate text-sm font-semibold">{{ template.name }}</p><DomBadge tone="neutral" variant="outline">{{ template.method }}</DomBadge></div>
<p class="mt-1 truncate font-mono text-xs text-muted-fg">{{ template.path }}</p>
<p class="mt-2 line-clamp-2 text-xs leading-5 text-muted-fg">{{ template.description }}</p>
</button>
<DomEmptyState v-if="!filteredTemplates.length" class="px-4 py-12" title="No matching templates" description="Try a method, path, or template name." />
</nav>
<nav v-else class="min-h-0 flex-1 divide-y divide-border overflow-y-auto" aria-label="Request history">
<button v-for="run in filteredRuns" :key="run.id" type="button" class="w-full border-l-2 px-4 py-4 text-left transition hover:bg-secondary/45 focus-visible:outline-2 focus-visible:outline-ring" :class="run.id === selectedRunId ? 'border-l-primary bg-secondary/55' : 'border-l-transparent'" @click="inspectRun(run)">
<div class="flex items-start justify-between gap-3"><p class="min-w-0 truncate font-mono text-xs font-semibold">{{ run.requestId }}</p><DomStatusPill :tone="run.statusTone" size="sm">{{ run.status }}</DomStatusPill></div>
<p class="mt-2 truncate text-sm font-medium">{{ run.method }} {{ run.path }}</p>
<p class="mt-1 text-xs text-muted-fg">{{ formatTime(run.ranAt) }} · {{ run.latencyMs }}ms</p>
</button>
<DomEmptyState v-if="!filteredRuns.length" class="px-4 py-12" title="No matching runs" description="Try a path or request identifier." />
</nav>
<div class="shrink-0 border-t border-border p-3"><DomButton class="w-full" size="sm" variant="ghost" @click="resetDialogOpen = true">Reset demo data</DomButton></div>
</aside>
<main class="min-h-0 min-w-0 flex-1 flex-col" :class="activeView === 'request' || activeView === 'response' ? 'flex' : 'hidden lg:flex'">
<section class="min-h-0 flex-1 flex-col border-b border-border lg:flex" :class="activeView === 'request' ? 'flex' : 'hidden'">
<div class="shrink-0 border-b border-border px-4 py-3 sm:px-5">
<div class="grid min-w-0 gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
<DomSelect v-model="draft.method" label="Method" :options="bootstrap.options.methods" :errors="fieldErrors.method || []" width="min-w-[12rem]"><template #option="{ option }"><div><p class="font-semibold">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-70">{{ option.description }}</p></div></template></DomSelect>
<DomTextInput v-model="draft.path" label="Request path" :errors="fieldErrors.path || []" />
</div>
<p class="mt-2 truncate font-mono text-xs text-muted-fg" :title="resolvedUrl">{{ resolvedUrl }}</p>
</div>
<DomTabs v-model="requestTab" :tabs="requestTabs" variant="page" fill class="min-h-0 flex-1">
<template #body><div class="h-full min-h-0 overflow-y-auto p-4 sm:p-5"><DomCodeInput v-model="draft.body" label="Request JSON" lang="json" :rows="13" :editor="false" :errors="fieldErrors.body || []" description="Template variables are resolved by the server proxy." /></div></template>
<template #authorization>
<div class="h-full min-h-0 overflow-y-auto p-4 sm:p-5">
<div class="grid gap-5 xl:grid-cols-2">
<DomSelect v-model="draft.environmentId" label="Environment" :options="bootstrap.options.environments" :errors="fieldErrors.environmentId || []" width="min-w-[20rem]"><template #option="{ option }"><div class="flex items-start justify-between gap-3"><div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-70">{{ option.description }}</p></div><DomBadge :tone="option.tone" variant="soft">{{ option.status }}</DomBadge></div></template></DomSelect>
<DomSelect v-model="draft.authMode" label="Authorization" :options="bootstrap.options.auth" :errors="fieldErrors.authMode || []" width="min-w-[20rem]"><template #option="{ option }"><div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-70">{{ option.description }}</p></div></template></DomSelect>
</div>
<div class="mt-5"><DomTagCombobox v-model="draft.scopes" :options="bootstrap.options.scopes" label="Granted scopes" placeholder="Add scope" :errors="fieldErrors.scopes || []" clearable><template #item="{ item }"><div><div class="flex items-center justify-between gap-3"><p class="font-medium">{{ item.label }}</p><span class="text-[11px] text-muted-fg">{{ item.group }}</span></div><p class="mt-0.5 text-xs text-muted-fg">{{ item.description }}</p></div></template></DomTagCombobox></div>
<div class="mt-5 border-t border-border pt-5"><DomRangeInput v-model="draft.timeoutMs" label="Timeout budget" :min="1000" :max="10000" :step="500" suffix="ms" :errors="fieldErrors.timeoutMs || []" /></div>
</div>
</template>
<template #headers><div class="h-full min-h-0 overflow-y-auto p-4 sm:p-5"><DomCodeInput v-model="draft.headers" label="Request headers" lang="json" :rows="11" :editor="false" :errors="fieldErrors.headers || []" description="Authorization is injected by the proxy and is never persisted in visible source." /></div></template>
</DomTabs>
<div class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3 sm:px-5">
<p class="text-xs text-muted-fg">{{ draft.timeoutMs }}ms timeout · {{ draft.scopes.length }} scopes · {{ selectedEnvironment?.status }}</p>
<div class="flex items-center gap-2"><DomButton size="sm" variant="secondary" @click="openSaveDialog">Save as</DomButton><DomButton v-if="isTemplateDirty" size="sm" variant="secondary" :loading="busy" @click="saveChanges">Save changes</DomButton><DomButton size="sm" :loading="busy" @click="requestSend">Send</DomButton></div>
</div>
</section>
<section class="min-h-0 flex-1 flex-col lg:flex" :class="activeView === 'response' ? 'flex' : 'hidden'">
<div class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
<div><p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Latest response</p><div class="mt-1 flex items-center gap-2"><h2 class="text-sm font-semibold">{{ responseSummary }}</h2><DomStatusPill v-if="selectedRun" :tone="selectedRun.statusTone" size="sm">{{ selectedRun.status }}</DomStatusPill></div></div>
<DomButton v-if="selectedRun" size="sm" variant="secondary" @click="loadRunRequest">Load request</DomButton>
</div>
<DomTabs v-if="selectedRun" v-model="responseTab" :tabs="responseTabs" variant="page" fill class="min-h-0 flex-1">
<template #response-body><div class="h-full min-h-0 overflow-y-auto p-4"><DomJsonViewer :value="selectedRun.response.body" title="Response body" filename="response.json" :preview-lines="18" /></div></template>
<template #response-headers><div class="h-full min-h-0 overflow-y-auto p-4"><DomJsonViewer :value="selectedRun.response.headers" title="Response headers" filename="headers.json" :preview-lines="18" /></div></template>
<template #timeline><div class="h-full min-h-0 divide-y divide-border overflow-y-auto border-y border-border px-4"><div v-for="step in selectedRun.timeline" :key="step.label" class="flex items-start justify-between gap-4 py-4"><div><p class="text-sm font-medium">{{ step.label }}</p><p class="mt-1 text-xs leading-5 text-muted-fg">{{ step.detail }}</p></div><div class="shrink-0 text-right"><DomStatusPill :tone="step.status === 'warning' ? 'warning' : 'success'" size="sm">{{ step.status }}</DomStatusPill><p class="mt-1 font-mono text-[11px] text-muted-fg">{{ step.duration }}</p></div></div></div></template>
</DomTabs>
<DomEmptyState v-else class="m-auto max-w-md py-12" title="No request evidence" description="Send a registered request to inspect body, headers, and proxy timeline."><template #actions><DomButton @click="activeView = 'request'">Compose request</DomButton></template></DomEmptyState>
</section>
</main>
<aside class="min-h-0 w-full shrink-0 flex-col border-l border-border bg-secondary/10 lg:flex lg:w-80" :class="activeView === 'context' ? 'flex' : 'hidden'">
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<div class="flex items-start justify-between gap-3"><div><p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Environment</p><h2 class="mt-1 text-lg font-semibold">{{ selectedEnvironment?.label }}</h2></div><DomStatusPill :tone="selectedEnvironment?.tone" size="sm">{{ selectedEnvironment?.status }}</DomStatusPill></div>
<p class="mt-2 text-sm leading-6 text-muted-fg">{{ selectedEnvironment?.description }}</p>
<div class="mt-5 divide-y divide-border border-y border-border"><div v-for="variable in selectedEnvironment?.variables" :key="variable.name" class="flex items-center justify-between gap-3 py-3"><div><p class="font-mono text-xs font-semibold">{{ variable.name }}</p><p class="mt-1 text-xs text-muted-fg">{{ variable.secret ? 'Server secret' : 'Public variable' }}</p></div><code class="text-xs">{{ variable.value }}</code></div></div>
<div class="mt-7"><p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Security boundary</p><dl class="mt-2 divide-y divide-border border-y border-border text-sm"><div class="flex items-start justify-between gap-4 py-3"><dt class="text-muted-fg">Credentials</dt><dd class="text-right font-medium">{{ bootstrap.policy.credentials }}</dd></div><div class="flex items-start justify-between gap-4 py-3"><dt class="text-muted-fg">Run history</dt><dd class="text-right font-medium">{{ bootstrap.policy.historyRetention }}</dd></div><div class="flex items-start justify-between gap-4 py-3"><dt class="text-muted-fg">Production writes</dt><dd class="text-right font-medium">Explicit confirmation</dd></div></dl></div>
<div v-if="selectedRun" class="mt-7"><p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Current evidence</p><dl class="mt-2 divide-y divide-border border-y border-border text-sm"><div class="flex items-center justify-between gap-4 py-3"><dt class="text-muted-fg">Request ID</dt><dd class="font-mono text-xs font-semibold">{{ selectedRun.requestId }}</dd></div><div class="flex items-center justify-between gap-4 py-3"><dt class="text-muted-fg">Rate remaining</dt><dd class="font-semibold">{{ rateLimitRemaining }}</dd></div><div class="flex items-center justify-between gap-4 py-3"><dt class="text-muted-fg">Region</dt><dd class="font-semibold">{{ selectedRun.response.headers['x-proxy-region'] }}</dd></div><div class="flex items-center justify-between gap-4 py-3"><dt class="text-muted-fg">Ran at</dt><dd class="text-right font-semibold">{{ formatTime(selectedRun.ranAt) }}</dd></div></dl></div>
<div v-if="selectedTemplate" class="mt-7"><p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Selected template</p><div class="mt-2 border-y border-border py-4"><div class="flex items-start justify-between gap-3"><div><p class="text-sm font-semibold">{{ selectedTemplate.name }}</p><p class="mt-1 text-xs text-muted-fg">{{ optionLabel(bootstrap.options.collections, selectedTemplate.collectionId) }} · rev {{ selectedTemplate.revision }}</p></div><DomBadge tone="neutral" variant="outline">{{ selectedTemplate.visibility }}</DomBadge></div><p class="mt-3 text-sm leading-6 text-muted-fg">{{ selectedTemplate.description }}</p></div></div>
</div>
</aside>
</div>
<DomDialog v-model="saveDialogOpen" title="Save request template" description="Store a reusable server-proxied request for this workspace.">
<div class="grid gap-4"><DomTextInput v-model="saveDraft.name" label="Template name" :errors="fieldErrors.name || []" /><DomTextareaInput v-model="saveDraft.description" label="When to use this request" :rows="3" :errors="fieldErrors.description || []" /><div class="grid gap-4 sm:grid-cols-2"><DomSelect v-model="saveDraft.collectionId" label="Collection" :options="bootstrap.options.collections" :errors="fieldErrors.collectionId || []" width="min-w-[16rem]"><template #option="{ option }"><div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-70">{{ option.description }}</p></div></template></DomSelect><DomSelect v-model="saveDraft.visibility" label="Visibility" :options="bootstrap.options.visibility" :errors="fieldErrors.visibility || []" width="min-w-[16rem]"><template #option="{ option }"><div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-70">{{ option.description }}</p></div></template></DomSelect></div></div>
<template #footer><DomButton variant="secondary" data-close>Cancel</DomButton><DomButton :loading="busy" @click="createTemplate">Save template</DomButton></template>
</DomDialog>
<DomDialog v-model="productionDialogOpen" title="Confirm production write" description="This request targets live customer data. The server records the operator, exact request snapshot, response, and environment.">
<DomAlert tone="warning" variant="soft" title="Live request" :description="`${draft.method} ${resolvedUrl}`" />
<div class="mt-4"><DomCheckbox v-model="productionAcknowledged" label="I reviewed the live method, path, payload, and granted scopes" description="This acknowledgement is required by the server before dispatch." :errors="fieldErrors.productionAcknowledged || []" /></div>
<template #footer><DomButton variant="secondary" data-close>Cancel</DomButton><DomButton variant="danger" :disabled="!productionAcknowledged" :loading="busy" @click="sendRequest">Send to production</DomButton></template>
</DomDialog>
<DomDialog v-model="resetDialogOpen" title="Reset the API console?" description="This clears process-local templates and runs, then restores the seeded developer workspace.">
<template #footer><DomButton variant="secondary" data-close>Keep workspace</DomButton><DomButton variant="danger" :loading="busy" @click="resetWorkspace">Reset demo</DomButton></template>
</DomDialog>
</div>
<div v-else class="grid h-full place-items-center p-6"><DomAlert tone="danger" title="API console unavailable" :description="errorMessage || 'The block API did not return a workspace.'"><template #actions><DomButton variant="secondary" @click="loadWorkspace">Try again</DomButton></template></DomAlert></div>
</div>
</template>
Integration
Included application behavior
This is a functioning repository demo rather than a timer-driven screenshot. The browser edits request drafts, while server routes own environment variables, credential injection, endpoint allowlisting, scope checks, production confirmation, template revisions, and immutable run evidence.
- Send a registered request and inspect its response body, headers, rate-limit evidence, and proxy timeline.
- Exercise 401, 403, 404, and timeout responses by changing authorization, scopes, paths, or timeout budgets.
- Create reusable team or private templates and update them with optimistic revision protection.
- Replay an immutable history entry without exposing the original credential.
- Switch to production and confirm a write explicitly before the server accepts it.
API
Repository-local route contract
GET /api/block-demos/api-request-console/bootstrap
GET /api/block-demos/api-request-console/templates/:templateId
POST /api/block-demos/api-request-console/templates
PATCH /api/block-demos/api-request-console/templates/:templateId
POST /api/block-demos/api-request-console/runs
GET /api/block-demos/api-request-console/runs/:runId
POST /api/block-demos/api-request-console/resetCustomization
Production boundaries
Security boundary
The browser never receives the credential. The server injects authorization, redacts matching headers, and records safe request evidence.
Demo persistence
The included store is process-local so the example works without setup. Replace it with workspace-scoped storage while preserving the route contract.
Production connection
Replace the deterministic endpoint catalog with an allowlisted upstream client, then derive endpoints and scopes from OpenAPI or your command registry.