Files
bambuddy/frontend/src/components/APIBrowser.tsx
T
maziggy 83cbac04b7 - Add interactive API Browser to Settings > API Keys
- New APIBrowser component with full OpenAPI schema integration
    - Fetches and parses /openapi.json automatically
    - Groups endpoints by API tags (printers, archives, settings, etc.)
    - Expandable endpoint sections with color-coded method badges
    - Path parameter, query parameter, and JSON body editors
    - Auto-populates request body with schema examples
    - Live API request execution with response display
    - Response shows status code, timing, and formatted JSON
    - Copy response button with clipboard fallback
    - Search to filter endpoints across all categories
    - Expand All / Collapse All buttons
    - Link to Swagger UI (/docs)

  - Two-column layout for API Keys tab
    - Left: API key management + webhook documentation
    - Right: API Browser with dedicated test key input

  - Parameter validation
    - Shows warning for missing required parameters
    - Validates before sending requests to avoid 422 errors

  - UX improvements
    - "Use in API Browser" button on newly created keys
    - Responsive layout (stacked on mobile, side-by-side on xl+)
2026-01-02 15:00:21 +01:00

649 lines
21 KiB
TypeScript

import { useState, useEffect } from 'react';
import { ChevronDown, ChevronRight, Play, Copy, Loader2, ExternalLink, AlertCircle, CheckCircle } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface OpenAPISchema {
paths: Record<string, Record<string, EndpointSpec>>;
components?: {
schemas?: Record<string, SchemaSpec>;
};
}
interface EndpointSpec {
summary?: string;
description?: string;
tags?: string[];
parameters?: ParameterSpec[];
requestBody?: {
content?: {
'application/json'?: {
schema?: SchemaSpec;
};
};
};
responses?: Record<string, ResponseSpec>;
}
interface ParameterSpec {
name: string;
in: 'path' | 'query' | 'header';
required?: boolean;
description?: string;
schema?: {
type?: string;
default?: unknown;
enum?: string[];
};
}
interface SchemaSpec {
type?: string;
properties?: Record<string, SchemaSpec>;
required?: string[];
items?: SchemaSpec;
$ref?: string;
allOf?: SchemaSpec[];
anyOf?: SchemaSpec[];
oneOf?: SchemaSpec[];
default?: unknown;
description?: string;
enum?: string[];
example?: unknown;
}
interface ResponseSpec {
description?: string;
content?: {
'application/json'?: {
schema?: SchemaSpec;
};
};
}
interface APIResponse {
status: number;
statusText: string;
headers: Record<string, string>;
body: unknown;
duration: number;
}
const METHOD_COLORS: Record<string, string> = {
get: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
post: 'bg-green-500/20 text-green-400 border-green-500/30',
put: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
patch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
delete: 'bg-red-500/20 text-red-400 border-red-500/30',
};
function resolveRef(schema: OpenAPISchema, ref: string): SchemaSpec {
// Parse $ref like "#/components/schemas/PrinterCreate"
const parts = ref.replace('#/', '').split('/');
let current: unknown = schema;
for (const part of parts) {
current = (current as Record<string, unknown>)[part];
}
return current as SchemaSpec;
}
function getSchemaExample(schema: OpenAPISchema, spec: SchemaSpec, depth = 0): unknown {
if (depth > 5) return '...';
if (spec.$ref) {
return getSchemaExample(schema, resolveRef(schema, spec.$ref), depth + 1);
}
if (spec.allOf) {
const merged: Record<string, unknown> = {};
for (const sub of spec.allOf) {
const subExample = getSchemaExample(schema, sub, depth + 1);
if (typeof subExample === 'object' && subExample !== null) {
Object.assign(merged, subExample);
}
}
return merged;
}
if (spec.example !== undefined) return spec.example;
if (spec.default !== undefined) return spec.default;
switch (spec.type) {
case 'string':
if (spec.enum) return spec.enum[0];
return 'string';
case 'integer':
case 'number':
return 0;
case 'boolean':
return false;
case 'array':
return spec.items ? [getSchemaExample(schema, spec.items, depth + 1)] : [];
case 'object':
if (spec.properties) {
const obj: Record<string, unknown> = {};
for (const [key, propSpec] of Object.entries(spec.properties)) {
obj[key] = getSchemaExample(schema, propSpec, depth + 1);
}
return obj;
}
return {};
default:
return null;
}
}
interface EndpointItemProps {
path: string;
method: string;
spec: EndpointSpec;
schema: OpenAPISchema;
apiKey: string;
}
function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps) {
const [expanded, setExpanded] = useState(false);
const [params, setParams] = useState<Record<string, string>>({});
const [bodyText, setBodyText] = useState('');
const [response, setResponse] = useState<APIResponse | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
// Initialize params with defaults
useEffect(() => {
if (expanded && spec.parameters) {
const defaults: Record<string, string> = {};
for (const param of spec.parameters) {
if (param.schema?.default !== undefined) {
defaults[param.name] = String(param.schema.default);
}
}
setParams(prev => ({ ...defaults, ...prev }));
}
}, [expanded, spec.parameters]);
// Initialize body with example
useEffect(() => {
if (expanded && spec.requestBody?.content?.['application/json']?.schema && !bodyText) {
const bodySchema = spec.requestBody.content['application/json'].schema;
const example = getSchemaExample(schema, bodySchema);
setBodyText(JSON.stringify(example, null, 2));
}
}, [expanded, spec.requestBody, schema, bodyText]);
// Check for missing required parameters
const getMissingParams = () => {
const missing: string[] = [];
for (const param of spec.parameters || []) {
if (param.in === 'path' || param.required) {
const value = params[param.name];
if (value === undefined || value === '') {
missing.push(param.name);
}
}
}
return missing;
};
const missingParams = getMissingParams();
const executeRequest = async () => {
if (missingParams.length > 0) {
setResponse({
status: 0,
statusText: 'Validation Error',
headers: {},
body: `Missing required parameters: ${missingParams.join(', ')}`,
duration: 0,
});
return;
}
setLoading(true);
setResponse(null);
try {
// Build URL with path and query params
let url = path;
const queryParams = new URLSearchParams();
for (const param of spec.parameters || []) {
const value = params[param.name];
if (value !== undefined && value !== '') {
if (param.in === 'path') {
url = url.replace(`{${param.name}}`, encodeURIComponent(value));
} else if (param.in === 'query') {
queryParams.append(param.name, value);
}
}
}
const queryString = queryParams.toString();
// OpenAPI paths already include /api/v1 prefix
const fullUrl = `${url}${queryString ? `?${queryString}` : ''}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
const options: RequestInit = {
method: method.toUpperCase(),
headers,
};
if (['post', 'put', 'patch'].includes(method) && bodyText) {
options.body = bodyText;
}
const startTime = performance.now();
const res = await fetch(fullUrl, options);
const duration = Math.round(performance.now() - startTime);
const responseHeaders: Record<string, string> = {};
res.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
let body: unknown;
const contentType = res.headers.get('content-type');
if (contentType?.includes('application/json')) {
body = await res.json();
} else {
body = await res.text();
}
setResponse({
status: res.status,
statusText: res.statusText,
headers: responseHeaders,
body,
duration,
});
} catch (err) {
setResponse({
status: 0,
statusText: 'Network Error',
headers: {},
body: err instanceof Error ? err.message : 'Unknown error',
duration: 0,
});
} finally {
setLoading(false);
}
};
const copyResponse = async () => {
if (response) {
const text = typeof response.body === 'string'
? response.body
: JSON.stringify(response.body, null, 2);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for non-HTTPS
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
const pathParams = (spec.parameters || []).filter(p => p.in === 'path');
const queryParamsSpec = (spec.parameters || []).filter(p => p.in === 'query');
const hasBody = ['post', 'put', 'patch'].includes(method) && spec.requestBody;
return (
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 p-3 hover:bg-bambu-dark-tertiary/50 transition-colors text-left"
>
{expanded ? (
<ChevronDown className="w-4 h-4 text-bambu-gray flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
)}
<span className={`px-2 py-0.5 text-xs font-mono font-semibold uppercase rounded border ${METHOD_COLORS[method] || 'bg-gray-500/20 text-gray-400'}`}>
{method}
</span>
<code className="text-sm text-white font-mono flex-1 truncate">{path}</code>
{spec.summary && (
<span className="text-sm text-bambu-gray truncate max-w-[40%]">{spec.summary}</span>
)}
</button>
{expanded && (
<div className="border-t border-bambu-dark-tertiary p-4 space-y-4 bg-bambu-dark/50">
{spec.description && (
<p className="text-sm text-bambu-gray">{spec.description}</p>
)}
{/* Path Parameters */}
{pathParams.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-white">Path Parameters</h4>
<div className="space-y-2">
{pathParams.map(param => (
<div key={param.name} className="flex items-center gap-2">
<label className="text-sm text-bambu-gray w-32 flex-shrink-0">
{param.name}
{param.required && <span className="text-red-400 ml-1">*</span>}
</label>
<input
type="text"
value={params[param.name] || ''}
onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
placeholder={param.description || param.schema?.type || 'value'}
className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
/>
</div>
))}
</div>
</div>
)}
{/* Query Parameters */}
{queryParamsSpec.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-white">Query Parameters</h4>
<div className="space-y-2">
{queryParamsSpec.map(param => (
<div key={param.name} className="flex items-center gap-2">
<label className="text-sm text-bambu-gray w-32 flex-shrink-0">
{param.name}
{param.required && <span className="text-red-400 ml-1">*</span>}
</label>
{param.schema?.enum ? (
<select
value={params[param.name] || ''}
onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
>
<option value="">-- Select --</option>
{param.schema.enum.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type="text"
value={params[param.name] || ''}
onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
placeholder={param.description || param.schema?.type || 'value'}
className="flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
/>
)}
</div>
))}
</div>
</div>
)}
{/* Request Body */}
{hasBody && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-white">Request Body</h4>
<textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={8}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono focus:border-bambu-green focus:outline-none resize-y"
placeholder="JSON request body..."
/>
</div>
)}
{/* Execute Button */}
<div className="flex items-center gap-2">
<Button onClick={executeRequest} disabled={loading}>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
Execute
</Button>
{missingParams.length > 0 && (
<span className="text-xs text-yellow-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Fill in: {missingParams.join(', ')}
</span>
)}
</div>
{/* Response */}
{response && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-white flex items-center gap-2">
Response
<span className={`px-2 py-0.5 text-xs rounded ${
response.status >= 200 && response.status < 300
? 'bg-green-500/20 text-green-400'
: response.status >= 400
? 'bg-red-500/20 text-red-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}>
{response.status} {response.statusText}
</span>
<span className="text-xs text-bambu-gray">{response.duration}ms</span>
</h4>
<Button variant="secondary" size="sm" onClick={copyResponse}>
{copied ? (
<CheckCircle className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
)}
</Button>
</div>
<pre className="p-3 bg-bambu-dark rounded-lg text-sm font-mono text-white overflow-auto max-h-96 border border-bambu-dark-tertiary">
{typeof response.body === 'string'
? response.body
: JSON.stringify(response.body, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
interface APIBrowserProps {
apiKey?: string;
}
export function APIBrowser({ apiKey = '' }: APIBrowserProps) {
const [schema, setSchema] = useState<OpenAPISchema | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
async function fetchSchema() {
try {
const res = await fetch('/openapi.json');
if (!res.ok) throw new Error('Failed to fetch OpenAPI schema');
const data = await res.json();
setSchema(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
fetchSchema();
}, []);
if (loading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
if (error || !schema) {
return (
<Card>
<CardContent className="py-8">
<div className="text-center text-red-400">
<AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Failed to load API schema</p>
<p className="text-sm text-bambu-gray mt-1">{error}</p>
</div>
</CardContent>
</Card>
);
}
// Group endpoints by tag
const endpointsByTag: Record<string, Array<{ path: string; method: string; spec: EndpointSpec }>> = {};
for (const [path, methods] of Object.entries(schema.paths)) {
for (const [method, spec] of Object.entries(methods)) {
if (method === 'parameters') continue; // Skip path-level parameters
const tags = spec.tags || ['Other'];
for (const tag of tags) {
if (!endpointsByTag[tag]) {
endpointsByTag[tag] = [];
}
endpointsByTag[tag].push({ path, method, spec });
}
}
}
// Filter endpoints based on search
const filteredTags = Object.entries(endpointsByTag)
.map(([tag, endpoints]) => {
if (!searchQuery) return { tag, endpoints };
const filtered = endpoints.filter(({ path, method, spec }) => {
const searchLower = searchQuery.toLowerCase();
return (
path.toLowerCase().includes(searchLower) ||
method.toLowerCase().includes(searchLower) ||
(spec.summary?.toLowerCase() || '').includes(searchLower) ||
(spec.description?.toLowerCase() || '').includes(searchLower)
);
});
return { tag, endpoints: filtered };
})
.filter(({ endpoints }) => endpoints.length > 0)
.sort((a, b) => a.tag.localeCompare(b.tag));
const toggleTag = (tag: string) => {
setExpandedTags(prev => {
const next = new Set(prev);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
return next;
});
};
const expandAll = () => {
setExpandedTags(new Set(filteredTags.map(t => t.tag)));
};
const collapseAll = () => {
setExpandedTags(new Set());
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search endpoints..."
className="w-full max-w-md px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={expandAll}>
Expand All
</Button>
<Button variant="secondary" size="sm" onClick={collapseAll}>
Collapse All
</Button>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-bambu-green hover:underline"
>
<ExternalLink className="w-4 h-4" />
Swagger UI
</a>
</div>
</div>
{/* Endpoint count */}
<p className="text-sm text-bambu-gray">
{filteredTags.reduce((acc, t) => acc + t.endpoints.length, 0)} endpoints in {filteredTags.length} categories
</p>
{/* Endpoints by Tag */}
<div className="space-y-3">
{filteredTags.map(({ tag, endpoints }) => (
<Card key={tag}>
<button
onClick={() => toggleTag(tag)}
className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/30 transition-colors text-left"
>
<div className="flex items-center gap-2">
{expandedTags.has(tag) ? (
<ChevronDown className="w-5 h-5 text-bambu-gray" />
) : (
<ChevronRight className="w-5 h-5 text-bambu-gray" />
)}
<h3 className="text-base font-semibold text-white capitalize">{tag.replace(/-/g, ' ')}</h3>
<span className="text-xs bg-bambu-dark-tertiary px-2 py-0.5 rounded-full text-bambu-gray">
{endpoints.length}
</span>
</div>
</button>
{expandedTags.has(tag) && (
<CardContent className="pt-0 space-y-2">
{endpoints.map(({ path, method, spec }) => (
<EndpointItem
key={`${method}-${path}`}
path={path}
method={method}
spec={spec}
schema={schema}
apiKey={apiKey}
/>
))}
</CardContent>
)}
</Card>
))}
</div>
</div>
);
}