Skip to content

Commit 6aff1d5

Browse files
committed
feat(api): replace ObjectApiReference with ObjectApiConsole for improved API interaction
1 parent fbb8137 commit 6aff1d5

2 files changed

Lines changed: 305 additions & 94 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { useState, useCallback } from 'react';
2+
import { useClient } from '@objectstack/client-react';
3+
import { Badge } from '@/components/ui/badge';
4+
import { Globe, Play, Copy, Check, ChevronDown, ChevronRight, Clock, Loader2 } from 'lucide-react';
5+
6+
// ─── Types ──────────────────────────────────────────────────────────
7+
8+
type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
9+
10+
interface EndpointDef {
11+
method: HttpMethod;
12+
path: string;
13+
desc: string;
14+
bodyTemplate?: Record<string, unknown>;
15+
}
16+
17+
interface RequestHistoryEntry {
18+
id: number;
19+
method: HttpMethod;
20+
url: string;
21+
body?: string;
22+
status: number;
23+
duration: number;
24+
response: string;
25+
timestamp: Date;
26+
}
27+
28+
// ─── Constants ──────────────────────────────────────────────────────
29+
30+
const METHOD_COLORS: Record<HttpMethod, string> = {
31+
GET: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/50',
32+
POST: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/50',
33+
PATCH: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50',
34+
DELETE: 'text-red-600 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50',
35+
};
36+
37+
const STATUS_COLORS: Record<string, string> = {
38+
'2': 'text-emerald-600 dark:text-emerald-400',
39+
'3': 'text-blue-600 dark:text-blue-400',
40+
'4': 'text-amber-600 dark:text-amber-400',
41+
'5': 'text-red-600 dark:text-red-400',
42+
};
43+
44+
// ─── Component ──────────────────────────────────────────────────────
45+
46+
interface ObjectApiConsoleProps {
47+
objectApiName: string;
48+
}
49+
50+
export function ObjectApiConsole({ objectApiName }: ObjectApiConsoleProps) {
51+
const client = useClient();
52+
53+
const endpoints: EndpointDef[] = [
54+
{ method: 'GET', path: `/api/v1/data/${objectApiName}`, desc: 'List records' },
55+
{ method: 'POST', path: `/api/v1/data/${objectApiName}`, desc: 'Create record', bodyTemplate: { name: 'example' } },
56+
{ method: 'GET', path: `/api/v1/data/${objectApiName}/:id`, desc: 'Get by ID' },
57+
{ method: 'PATCH', path: `/api/v1/data/${objectApiName}/:id`, desc: 'Update record', bodyTemplate: { name: 'updated' } },
58+
{ method: 'DELETE', path: `/api/v1/data/${objectApiName}/:id`, desc: 'Delete record' },
59+
{ method: 'GET', path: `/api/v1/meta/object/${objectApiName}`, desc: 'Object schema' },
60+
];
61+
62+
const [selectedEndpoint, setSelectedEndpoint] = useState<EndpointDef>(endpoints[0]);
63+
const [urlOverride, setUrlOverride] = useState('');
64+
const [requestBody, setRequestBody] = useState('');
65+
const [loading, setLoading] = useState(false);
66+
const [response, setResponse] = useState<{ status: number; body: string; duration: number } | null>(null);
67+
const [history, setHistory] = useState<RequestHistoryEntry[]>([]);
68+
const [expandedHistory, setExpandedHistory] = useState<number | null>(null);
69+
const [copied, setCopied] = useState(false);
70+
71+
// Resolve effective URL (replace :id placeholder, allow override)
72+
const effectiveUrl = urlOverride || selectedEndpoint.path;
73+
74+
const selectEndpoint = useCallback((ep: EndpointDef) => {
75+
setSelectedEndpoint(ep);
76+
setUrlOverride(ep.path);
77+
setRequestBody(ep.bodyTemplate ? JSON.stringify(ep.bodyTemplate, null, 2) : '');
78+
setResponse(null);
79+
}, []);
80+
81+
const sendRequest = useCallback(async () => {
82+
if (loading) return;
83+
84+
// Resolve the base URL from the client
85+
const baseUrl = (client as any)?.baseUrl ?? '';
86+
const fullUrl = `${baseUrl}${effectiveUrl}`;
87+
88+
setLoading(true);
89+
const start = performance.now();
90+
91+
try {
92+
const fetchOptions: RequestInit = {
93+
method: selectedEndpoint.method,
94+
headers: { 'Content-Type': 'application/json' },
95+
};
96+
97+
if (['POST', 'PATCH'].includes(selectedEndpoint.method) && requestBody.trim()) {
98+
fetchOptions.body = requestBody;
99+
}
100+
101+
const res = await fetch(fullUrl, fetchOptions);
102+
const duration = Math.round(performance.now() - start);
103+
104+
let bodyText: string;
105+
const contentType = res.headers.get('content-type') ?? '';
106+
if (contentType.includes('json')) {
107+
const json = await res.json();
108+
bodyText = JSON.stringify(json, null, 2);
109+
} else {
110+
bodyText = await res.text();
111+
}
112+
113+
const result = { status: res.status, body: bodyText, duration };
114+
setResponse(result);
115+
116+
// Add to history (newest first, keep last 20)
117+
setHistory(prev => [{
118+
id: Date.now(),
119+
method: selectedEndpoint.method,
120+
url: effectiveUrl,
121+
body: requestBody || undefined,
122+
status: res.status,
123+
duration,
124+
response: bodyText,
125+
timestamp: new Date(),
126+
}, ...prev].slice(0, 20));
127+
} catch (err: any) {
128+
const duration = Math.round(performance.now() - start);
129+
setResponse({
130+
status: 0,
131+
body: `Network Error: ${err.message}`,
132+
duration,
133+
});
134+
} finally {
135+
setLoading(false);
136+
}
137+
}, [client, effectiveUrl, selectedEndpoint, requestBody, loading]);
138+
139+
const copyResponse = useCallback(() => {
140+
if (response?.body) {
141+
navigator.clipboard.writeText(response.body);
142+
setCopied(true);
143+
setTimeout(() => setCopied(false), 1500);
144+
}
145+
}, [response]);
146+
147+
const statusColor = (status: number) => STATUS_COLORS[String(status)[0]] ?? 'text-muted-foreground';
148+
149+
return (
150+
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 h-full">
151+
152+
{/* ── Left: Endpoint List ─────────────────────────────── */}
153+
<div className="space-y-3 lg:border-r lg:pr-4">
154+
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Endpoints</h3>
155+
<div className="space-y-1">
156+
{endpoints.map((ep, i) => {
157+
const isActive = ep === selectedEndpoint;
158+
return (
159+
<button
160+
key={i}
161+
onClick={() => selectEndpoint(ep)}
162+
className={`w-full text-left flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
163+
isActive
164+
? 'bg-accent text-accent-foreground'
165+
: 'hover:bg-muted/50 text-muted-foreground hover:text-foreground'
166+
}`}
167+
>
168+
<Badge variant="outline" className={`font-mono text-[10px] shrink-0 ${METHOD_COLORS[ep.method]}`}>
169+
{ep.method}
170+
</Badge>
171+
<span className="truncate text-xs">{ep.desc}</span>
172+
</button>
173+
);
174+
})}
175+
</div>
176+
177+
{/* Query params cheatsheet */}
178+
<div className="pt-3 border-t">
179+
<h4 className="text-xs font-medium text-muted-foreground mb-2">Query Parameters</h4>
180+
<div className="space-y-1 text-xs text-muted-foreground">
181+
<p><code className="text-foreground">?$top=10</code> — limit</p>
182+
<p><code className="text-foreground">?$skip=20</code> — offset</p>
183+
<p><code className="text-foreground">?$sort=name</code> — sort</p>
184+
<p><code className="text-foreground">?$select=name,email</code> — fields</p>
185+
<p><code className="text-foreground">?$count=true</code> — total count</p>
186+
</div>
187+
</div>
188+
</div>
189+
190+
{/* ── Right: Request / Response ──────────────────────── */}
191+
<div className="flex flex-col gap-3 min-w-0">
192+
193+
{/* URL bar */}
194+
<div className="flex items-center gap-2">
195+
<Badge variant="outline" className={`font-mono text-xs shrink-0 ${METHOD_COLORS[selectedEndpoint.method]}`}>
196+
{selectedEndpoint.method}
197+
</Badge>
198+
<input
199+
type="text"
200+
value={urlOverride || effectiveUrl}
201+
onChange={e => setUrlOverride(e.target.value)}
202+
className="flex-1 rounded-md border bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
203+
placeholder={selectedEndpoint.path}
204+
/>
205+
<button
206+
onClick={sendRequest}
207+
disabled={loading}
208+
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
209+
>
210+
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
211+
Send
212+
</button>
213+
</div>
214+
215+
{/* Request Body (for POST/PATCH) */}
216+
{['POST', 'PATCH'].includes(selectedEndpoint.method) && (
217+
<div className="space-y-1">
218+
<label className="text-xs font-medium text-muted-foreground">Request Body (JSON)</label>
219+
<textarea
220+
value={requestBody}
221+
onChange={e => setRequestBody(e.target.value)}
222+
rows={4}
223+
className="w-full rounded-md border bg-background px-3 py-2 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring resize-y"
224+
placeholder='{ "name": "value" }'
225+
/>
226+
</div>
227+
)}
228+
229+
{/* Response */}
230+
{response && (
231+
<div className="flex-1 min-h-0 flex flex-col rounded-lg border overflow-hidden">
232+
<div className="flex items-center justify-between px-3 py-2 bg-muted/30 border-b">
233+
<div className="flex items-center gap-3 text-sm">
234+
<span className={`font-mono font-semibold ${statusColor(response.status)}`}>
235+
{response.status || 'ERR'}
236+
</span>
237+
<span className="text-muted-foreground flex items-center gap-1">
238+
<Clock className="h-3 w-3" />
239+
{response.duration}ms
240+
</span>
241+
</div>
242+
<button
243+
onClick={copyResponse}
244+
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
245+
>
246+
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
247+
{copied ? 'Copied' : 'Copy'}
248+
</button>
249+
</div>
250+
<pre className="flex-1 overflow-auto p-3 text-xs font-mono bg-muted/10 whitespace-pre-wrap break-all">
251+
{response.body}
252+
</pre>
253+
</div>
254+
)}
255+
256+
{/* No response yet */}
257+
{!response && !loading && (
258+
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
259+
<div className="text-center space-y-1">
260+
<Globe className="h-8 w-8 mx-auto opacity-30" />
261+
<p>Select an endpoint and click <strong>Send</strong> to test</p>
262+
</div>
263+
</div>
264+
)}
265+
266+
{/* History */}
267+
{history.length > 0 && (
268+
<div className="border-t pt-3 space-y-1">
269+
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</h4>
270+
<div className="space-y-1 max-h-48 overflow-auto">
271+
{history.map(entry => (
272+
<div key={entry.id} className="rounded border">
273+
<button
274+
onClick={() => setExpandedHistory(expandedHistory === entry.id ? null : entry.id)}
275+
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-muted/30 transition-colors"
276+
>
277+
{expandedHistory === entry.id
278+
? <ChevronDown className="h-3 w-3 shrink-0" />
279+
: <ChevronRight className="h-3 w-3 shrink-0" />
280+
}
281+
<Badge variant="outline" className={`font-mono text-[9px] shrink-0 ${METHOD_COLORS[entry.method]}`}>
282+
{entry.method}
283+
</Badge>
284+
<span className="font-mono truncate">{entry.url}</span>
285+
<span className={`ml-auto shrink-0 font-mono ${statusColor(entry.status)}`}>
286+
{entry.status}
287+
</span>
288+
<span className="shrink-0 text-muted-foreground">{entry.duration}ms</span>
289+
</button>
290+
{expandedHistory === entry.id && (
291+
<pre className="px-3 py-2 text-xs font-mono bg-muted/10 border-t overflow-auto max-h-40 whitespace-pre-wrap break-all">
292+
{entry.response}
293+
</pre>
294+
)}
295+
</div>
296+
))}
297+
</div>
298+
</div>
299+
)}
300+
</div>
301+
</div>
302+
);
303+
}

0 commit comments

Comments
 (0)