APIBrowser.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import { useState, useEffect } from 'react';
  2. import { ChevronDown, ChevronRight, Play, Copy, Loader2, ExternalLink, AlertCircle, CheckCircle } from 'lucide-react';
  3. import { Card, CardContent } from './Card';
  4. import { Button } from './Button';
  5. interface OpenAPISchema {
  6. paths: Record<string, Record<string, EndpointSpec>>;
  7. components?: {
  8. schemas?: Record<string, SchemaSpec>;
  9. };
  10. }
  11. interface EndpointSpec {
  12. summary?: string;
  13. description?: string;
  14. tags?: string[];
  15. parameters?: ParameterSpec[];
  16. requestBody?: {
  17. content?: {
  18. 'application/json'?: {
  19. schema?: SchemaSpec;
  20. };
  21. };
  22. };
  23. responses?: Record<string, ResponseSpec>;
  24. }
  25. interface ParameterSpec {
  26. name: string;
  27. in: 'path' | 'query' | 'header';
  28. required?: boolean;
  29. description?: string;
  30. schema?: {
  31. type?: string;
  32. default?: unknown;
  33. enum?: string[];
  34. };
  35. }
  36. interface SchemaSpec {
  37. type?: string;
  38. properties?: Record<string, SchemaSpec>;
  39. required?: string[];
  40. items?: SchemaSpec;
  41. $ref?: string;
  42. allOf?: SchemaSpec[];
  43. anyOf?: SchemaSpec[];
  44. oneOf?: SchemaSpec[];
  45. default?: unknown;
  46. description?: string;
  47. enum?: string[];
  48. example?: unknown;
  49. }
  50. interface ResponseSpec {
  51. description?: string;
  52. content?: {
  53. 'application/json'?: {
  54. schema?: SchemaSpec;
  55. };
  56. };
  57. }
  58. interface APIResponse {
  59. status: number;
  60. statusText: string;
  61. headers: Record<string, string>;
  62. body: unknown;
  63. duration: number;
  64. }
  65. const METHOD_COLORS: Record<string, string> = {
  66. get: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
  67. post: 'bg-green-500/20 text-green-400 border-green-500/30',
  68. put: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
  69. patch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
  70. delete: 'bg-red-500/20 text-red-400 border-red-500/30',
  71. };
  72. function resolveRef(schema: OpenAPISchema, ref: string): SchemaSpec {
  73. // Parse $ref like "#/components/schemas/PrinterCreate"
  74. const parts = ref.replace('#/', '').split('/');
  75. let current: unknown = schema;
  76. for (const part of parts) {
  77. current = (current as Record<string, unknown>)[part];
  78. }
  79. return current as SchemaSpec;
  80. }
  81. function getSchemaExample(schema: OpenAPISchema, spec: SchemaSpec, depth = 0): unknown {
  82. if (depth > 5) return '...';
  83. if (spec.$ref) {
  84. return getSchemaExample(schema, resolveRef(schema, spec.$ref), depth + 1);
  85. }
  86. if (spec.allOf) {
  87. const merged: Record<string, unknown> = {};
  88. for (const sub of spec.allOf) {
  89. const subExample = getSchemaExample(schema, sub, depth + 1);
  90. if (typeof subExample === 'object' && subExample !== null) {
  91. Object.assign(merged, subExample);
  92. }
  93. }
  94. return merged;
  95. }
  96. if (spec.example !== undefined) return spec.example;
  97. if (spec.default !== undefined) return spec.default;
  98. switch (spec.type) {
  99. case 'string':
  100. if (spec.enum) return spec.enum[0];
  101. return 'string';
  102. case 'integer':
  103. case 'number':
  104. return 0;
  105. case 'boolean':
  106. return false;
  107. case 'array':
  108. return spec.items ? [getSchemaExample(schema, spec.items, depth + 1)] : [];
  109. case 'object':
  110. if (spec.properties) {
  111. const obj: Record<string, unknown> = {};
  112. for (const [key, propSpec] of Object.entries(spec.properties)) {
  113. obj[key] = getSchemaExample(schema, propSpec, depth + 1);
  114. }
  115. return obj;
  116. }
  117. return {};
  118. default:
  119. return null;
  120. }
  121. }
  122. interface EndpointItemProps {
  123. path: string;
  124. method: string;
  125. spec: EndpointSpec;
  126. schema: OpenAPISchema;
  127. apiKey: string;
  128. }
  129. function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps) {
  130. const [expanded, setExpanded] = useState(false);
  131. const [params, setParams] = useState<Record<string, string>>({});
  132. const [bodyText, setBodyText] = useState('');
  133. const [response, setResponse] = useState<APIResponse | null>(null);
  134. const [loading, setLoading] = useState(false);
  135. const [copied, setCopied] = useState(false);
  136. // Initialize params with defaults
  137. useEffect(() => {
  138. if (expanded && spec.parameters) {
  139. const defaults: Record<string, string> = {};
  140. for (const param of spec.parameters) {
  141. if (param.schema?.default !== undefined) {
  142. defaults[param.name] = String(param.schema.default);
  143. }
  144. }
  145. setParams(prev => ({ ...defaults, ...prev }));
  146. }
  147. }, [expanded, spec.parameters]);
  148. // Initialize body with example
  149. useEffect(() => {
  150. if (expanded && spec.requestBody?.content?.['application/json']?.schema && !bodyText) {
  151. const bodySchema = spec.requestBody.content['application/json'].schema;
  152. const example = getSchemaExample(schema, bodySchema);
  153. setBodyText(JSON.stringify(example, null, 2));
  154. }
  155. }, [expanded, spec.requestBody, schema, bodyText]);
  156. // Check for missing required parameters
  157. const getMissingParams = () => {
  158. const missing: string[] = [];
  159. for (const param of spec.parameters || []) {
  160. if (param.in === 'path' || param.required) {
  161. const value = params[param.name];
  162. if (value === undefined || value === '') {
  163. missing.push(param.name);
  164. }
  165. }
  166. }
  167. return missing;
  168. };
  169. const missingParams = getMissingParams();
  170. const executeRequest = async () => {
  171. if (missingParams.length > 0) {
  172. setResponse({
  173. status: 0,
  174. statusText: 'Validation Error',
  175. headers: {},
  176. body: `Missing required parameters: ${missingParams.join(', ')}`,
  177. duration: 0,
  178. });
  179. return;
  180. }
  181. setLoading(true);
  182. setResponse(null);
  183. try {
  184. // Build URL with path and query params
  185. let url = path;
  186. const queryParams = new URLSearchParams();
  187. for (const param of spec.parameters || []) {
  188. const value = params[param.name];
  189. if (value !== undefined && value !== '') {
  190. if (param.in === 'path') {
  191. url = url.replace(`{${param.name}}`, encodeURIComponent(value));
  192. } else if (param.in === 'query') {
  193. queryParams.append(param.name, value);
  194. }
  195. }
  196. }
  197. const queryString = queryParams.toString();
  198. // OpenAPI paths already include /api/v1 prefix
  199. const fullUrl = `${url}${queryString ? `?${queryString}` : ''}`;
  200. const headers: Record<string, string> = {
  201. 'Content-Type': 'application/json',
  202. };
  203. if (apiKey) {
  204. headers['X-API-Key'] = apiKey;
  205. }
  206. const options: RequestInit = {
  207. method: method.toUpperCase(),
  208. headers,
  209. };
  210. if (['post', 'put', 'patch'].includes(method) && bodyText) {
  211. options.body = bodyText;
  212. }
  213. const startTime = performance.now();
  214. const res = await fetch(fullUrl, options);
  215. const duration = Math.round(performance.now() - startTime);
  216. const responseHeaders: Record<string, string> = {};
  217. res.headers.forEach((value, key) => {
  218. responseHeaders[key] = value;
  219. });
  220. let body: unknown;
  221. const contentType = res.headers.get('content-type');
  222. if (contentType?.includes('application/json')) {
  223. body = await res.json();
  224. } else {
  225. body = await res.text();
  226. }
  227. setResponse({
  228. status: res.status,
  229. statusText: res.statusText,
  230. headers: responseHeaders,
  231. body,
  232. duration,
  233. });
  234. } catch (err) {
  235. setResponse({
  236. status: 0,
  237. statusText: 'Network Error',
  238. headers: {},
  239. body: err instanceof Error ? err.message : 'Unknown error',
  240. duration: 0,
  241. });
  242. } finally {
  243. setLoading(false);
  244. }
  245. };
  246. const copyResponse = async () => {
  247. if (response) {
  248. const text = typeof response.body === 'string'
  249. ? response.body
  250. : JSON.stringify(response.body, null, 2);
  251. try {
  252. await navigator.clipboard.writeText(text);
  253. setCopied(true);
  254. setTimeout(() => setCopied(false), 2000);
  255. } catch {
  256. // Fallback for non-HTTPS
  257. const textArea = document.createElement('textarea');
  258. textArea.value = text;
  259. textArea.style.position = 'fixed';
  260. textArea.style.left = '-999999px';
  261. document.body.appendChild(textArea);
  262. textArea.select();
  263. document.execCommand('copy');
  264. document.body.removeChild(textArea);
  265. setCopied(true);
  266. setTimeout(() => setCopied(false), 2000);
  267. }
  268. }
  269. };
  270. const pathParams = (spec.parameters || []).filter(p => p.in === 'path');
  271. const queryParamsSpec = (spec.parameters || []).filter(p => p.in === 'query');
  272. const hasBody = ['post', 'put', 'patch'].includes(method) && spec.requestBody;
  273. return (
  274. <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  275. <button
  276. onClick={() => setExpanded(!expanded)}
  277. className="w-full flex items-center gap-3 p-3 hover:bg-bambu-dark-tertiary/50 transition-colors text-left"
  278. >
  279. {expanded ? (
  280. <ChevronDown className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  281. ) : (
  282. <ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  283. )}
  284. <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'}`}>
  285. {method}
  286. </span>
  287. <code className="text-sm text-white font-mono flex-1 truncate">{path}</code>
  288. {spec.summary && (
  289. <span className="text-sm text-bambu-gray truncate max-w-[40%]">{spec.summary}</span>
  290. )}
  291. </button>
  292. {expanded && (
  293. <div className="border-t border-bambu-dark-tertiary p-4 space-y-4 bg-bambu-dark/50">
  294. {spec.description && (
  295. <p className="text-sm text-bambu-gray">{spec.description}</p>
  296. )}
  297. {/* Path Parameters */}
  298. {pathParams.length > 0 && (
  299. <div className="space-y-2">
  300. <h4 className="text-sm font-medium text-white">Path Parameters</h4>
  301. <div className="space-y-2">
  302. {pathParams.map(param => (
  303. <div key={param.name} className="flex items-center gap-2">
  304. <label className="text-sm text-bambu-gray w-32 flex-shrink-0">
  305. {param.name}
  306. {param.required && <span className="text-red-400 ml-1">*</span>}
  307. </label>
  308. <input
  309. type="text"
  310. value={params[param.name] || ''}
  311. onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
  312. placeholder={param.description || param.schema?.type || 'value'}
  313. 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"
  314. />
  315. </div>
  316. ))}
  317. </div>
  318. </div>
  319. )}
  320. {/* Query Parameters */}
  321. {queryParamsSpec.length > 0 && (
  322. <div className="space-y-2">
  323. <h4 className="text-sm font-medium text-white">Query Parameters</h4>
  324. <div className="space-y-2">
  325. {queryParamsSpec.map(param => (
  326. <div key={param.name} className="flex items-center gap-2">
  327. <label className="text-sm text-bambu-gray w-32 flex-shrink-0">
  328. {param.name}
  329. {param.required && <span className="text-red-400 ml-1">*</span>}
  330. </label>
  331. {param.schema?.enum ? (
  332. <select
  333. value={params[param.name] || ''}
  334. onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
  335. 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"
  336. >
  337. <option value="">-- Select --</option>
  338. {param.schema.enum.map(opt => (
  339. <option key={opt} value={opt}>{opt}</option>
  340. ))}
  341. </select>
  342. ) : (
  343. <input
  344. type="text"
  345. value={params[param.name] || ''}
  346. onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}
  347. placeholder={param.description || param.schema?.type || 'value'}
  348. 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"
  349. />
  350. )}
  351. </div>
  352. ))}
  353. </div>
  354. </div>
  355. )}
  356. {/* Request Body */}
  357. {hasBody && (
  358. <div className="space-y-2">
  359. <h4 className="text-sm font-medium text-white">Request Body</h4>
  360. <textarea
  361. value={bodyText}
  362. onChange={(e) => setBodyText(e.target.value)}
  363. rows={8}
  364. 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"
  365. placeholder="JSON request body..."
  366. />
  367. </div>
  368. )}
  369. {/* Execute Button */}
  370. <div className="flex items-center gap-2">
  371. <Button onClick={executeRequest} disabled={loading}>
  372. {loading ? (
  373. <Loader2 className="w-4 h-4 animate-spin" />
  374. ) : (
  375. <Play className="w-4 h-4" />
  376. )}
  377. Execute
  378. </Button>
  379. {missingParams.length > 0 && (
  380. <span className="text-xs text-yellow-400 flex items-center gap-1">
  381. <AlertCircle className="w-3 h-3" />
  382. Fill in: {missingParams.join(', ')}
  383. </span>
  384. )}
  385. </div>
  386. {/* Response */}
  387. {response && (
  388. <div className="space-y-2">
  389. <div className="flex items-center justify-between">
  390. <h4 className="text-sm font-medium text-white flex items-center gap-2">
  391. Response
  392. <span className={`px-2 py-0.5 text-xs rounded ${
  393. response.status >= 200 && response.status < 300
  394. ? 'bg-green-500/20 text-green-400'
  395. : response.status >= 400
  396. ? 'bg-red-500/20 text-red-400'
  397. : 'bg-yellow-500/20 text-yellow-400'
  398. }`}>
  399. {response.status} {response.statusText}
  400. </span>
  401. <span className="text-xs text-bambu-gray">{response.duration}ms</span>
  402. </h4>
  403. <Button variant="secondary" size="sm" onClick={copyResponse}>
  404. {copied ? (
  405. <CheckCircle className="w-3 h-3 text-green-400" />
  406. ) : (
  407. <Copy className="w-3 h-3" />
  408. )}
  409. </Button>
  410. </div>
  411. <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">
  412. {typeof response.body === 'string'
  413. ? response.body
  414. : JSON.stringify(response.body, null, 2)}
  415. </pre>
  416. </div>
  417. )}
  418. </div>
  419. )}
  420. </div>
  421. );
  422. }
  423. interface APIBrowserProps {
  424. apiKey?: string;
  425. }
  426. export function APIBrowser({ apiKey = '' }: APIBrowserProps) {
  427. const [schema, setSchema] = useState<OpenAPISchema | null>(null);
  428. const [loading, setLoading] = useState(true);
  429. const [error, setError] = useState<string | null>(null);
  430. const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
  431. const [searchQuery, setSearchQuery] = useState('');
  432. useEffect(() => {
  433. async function fetchSchema() {
  434. try {
  435. const res = await fetch('/openapi.json');
  436. if (!res.ok) throw new Error('Failed to fetch OpenAPI schema');
  437. const data = await res.json();
  438. setSchema(data);
  439. } catch (err) {
  440. setError(err instanceof Error ? err.message : 'Unknown error');
  441. } finally {
  442. setLoading(false);
  443. }
  444. }
  445. fetchSchema();
  446. }, []);
  447. if (loading) {
  448. return (
  449. <div className="flex justify-center py-12">
  450. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  451. </div>
  452. );
  453. }
  454. if (error || !schema) {
  455. return (
  456. <Card>
  457. <CardContent className="py-8">
  458. <div className="text-center text-red-400">
  459. <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-50" />
  460. <p>Failed to load API schema</p>
  461. <p className="text-sm text-bambu-gray mt-1">{error}</p>
  462. </div>
  463. </CardContent>
  464. </Card>
  465. );
  466. }
  467. // Group endpoints by tag
  468. const endpointsByTag: Record<string, Array<{ path: string; method: string; spec: EndpointSpec }>> = {};
  469. for (const [path, methods] of Object.entries(schema.paths)) {
  470. for (const [method, spec] of Object.entries(methods)) {
  471. if (method === 'parameters') continue; // Skip path-level parameters
  472. const tags = spec.tags || ['Other'];
  473. for (const tag of tags) {
  474. if (!endpointsByTag[tag]) {
  475. endpointsByTag[tag] = [];
  476. }
  477. endpointsByTag[tag].push({ path, method, spec });
  478. }
  479. }
  480. }
  481. // Filter endpoints based on search
  482. const filteredTags = Object.entries(endpointsByTag)
  483. .map(([tag, endpoints]) => {
  484. if (!searchQuery) return { tag, endpoints };
  485. const filtered = endpoints.filter(({ path, method, spec }) => {
  486. const searchLower = searchQuery.toLowerCase();
  487. return (
  488. path.toLowerCase().includes(searchLower) ||
  489. method.toLowerCase().includes(searchLower) ||
  490. (spec.summary?.toLowerCase() || '').includes(searchLower) ||
  491. (spec.description?.toLowerCase() || '').includes(searchLower)
  492. );
  493. });
  494. return { tag, endpoints: filtered };
  495. })
  496. .filter(({ endpoints }) => endpoints.length > 0)
  497. .sort((a, b) => a.tag.localeCompare(b.tag));
  498. const toggleTag = (tag: string) => {
  499. setExpandedTags(prev => {
  500. const next = new Set(prev);
  501. if (next.has(tag)) {
  502. next.delete(tag);
  503. } else {
  504. next.add(tag);
  505. }
  506. return next;
  507. });
  508. };
  509. const expandAll = () => {
  510. setExpandedTags(new Set(filteredTags.map(t => t.tag)));
  511. };
  512. const collapseAll = () => {
  513. setExpandedTags(new Set());
  514. };
  515. return (
  516. <div className="space-y-4">
  517. {/* Header */}
  518. <div className="flex items-center justify-between gap-4">
  519. <div className="flex-1">
  520. <input
  521. type="text"
  522. value={searchQuery}
  523. onChange={(e) => setSearchQuery(e.target.value)}
  524. placeholder="Search endpoints..."
  525. 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"
  526. />
  527. </div>
  528. <div className="flex items-center gap-2">
  529. <Button variant="secondary" size="sm" onClick={expandAll}>
  530. Expand All
  531. </Button>
  532. <Button variant="secondary" size="sm" onClick={collapseAll}>
  533. Collapse All
  534. </Button>
  535. <a
  536. href="/docs"
  537. target="_blank"
  538. rel="noopener noreferrer"
  539. className="flex items-center gap-1 text-sm text-bambu-green hover:underline"
  540. >
  541. <ExternalLink className="w-4 h-4" />
  542. Swagger UI
  543. </a>
  544. </div>
  545. </div>
  546. {/* Endpoint count */}
  547. <p className="text-sm text-bambu-gray">
  548. {filteredTags.reduce((acc, t) => acc + t.endpoints.length, 0)} endpoints in {filteredTags.length} categories
  549. </p>
  550. {/* Endpoints by Tag */}
  551. <div className="space-y-3">
  552. {filteredTags.map(({ tag, endpoints }) => (
  553. <Card key={tag}>
  554. <button
  555. onClick={() => toggleTag(tag)}
  556. className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/30 transition-colors text-left"
  557. >
  558. <div className="flex items-center gap-2">
  559. {expandedTags.has(tag) ? (
  560. <ChevronDown className="w-5 h-5 text-bambu-gray" />
  561. ) : (
  562. <ChevronRight className="w-5 h-5 text-bambu-gray" />
  563. )}
  564. <h3 className="text-base font-semibold text-white capitalize">{tag.replace(/-/g, ' ')}</h3>
  565. <span className="text-xs bg-bambu-dark-tertiary px-2 py-0.5 rounded-full text-bambu-gray">
  566. {endpoints.length}
  567. </span>
  568. </div>
  569. </button>
  570. {expandedTags.has(tag) && (
  571. <CardContent className="pt-0 space-y-2">
  572. {endpoints.map(({ path, method, spec }) => (
  573. <EndpointItem
  574. key={`${method}-${path}`}
  575. path={path}
  576. method={method}
  577. spec={spec}
  578. schema={schema}
  579. apiKey={apiKey}
  580. />
  581. ))}
  582. </CardContent>
  583. )}
  584. </Card>
  585. ))}
  586. </div>
  587. </div>
  588. );
  589. }