diff --git a/src/features/api-tester/ApiTester.tsx b/src/features/api-tester/ApiTester.tsx index 91bdb32..6794224 100644 --- a/src/features/api-tester/ApiTester.tsx +++ b/src/features/api-tester/ApiTester.tsx @@ -1,18 +1,33 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import ToolTextarea from "../../components/tool/ToolTextarea"; import Button from "../../ui/Button"; import CopyButton from "../../ui/CopyButton"; -import { - apiRequest, - HTTP_METHODS, - type HttpMethod, - type ApiResponse, -} from "../../utils/apiTester/apiRequest"; - -type Header = { - key: string; - value: string; -}; +import { apiRequest, type Header } from "../../utils/apiTester/apiRequest"; + +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +interface ApiResponse { + status: number; + statusText: string; + timeMs: number; + data: string; + isError: boolean; +} + +interface HistoryItem { + method: HttpMethod; + url: string; + body: string; + headers: Header[]; + response: ApiResponse; +} + +const METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + +const DEFAULT_HEADERS: Header[] = [ + { key: "Content-Type", value: "application/json" }, + { key: "Accept", value: "application/json" }, +]; export default function ApiTester() { const [method, setMethod] = useState("GET"); @@ -22,27 +37,26 @@ export default function ApiTester() { const [reqBody, setReqBody] = useState( '{\n "title": "foo",\n "body": "bar",\n "userId": 1\n}', ); + + const [headers, setHeaders] = useState(DEFAULT_HEADERS); + const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); - const [headers, setHeaders] = useState([ - { key: "Content-Type", value: "application/json" }, - ]); + const [history, setHistory] = useState([]); + const [selectedHistory, setSelectedHistory] = useState(null); const hasBody = ["POST", "PUT", "PATCH"].includes(method); - const addHeader = () => { - setHeaders((prev) => [...prev, { key: "", value: "" }]); - }; - - const updateHeader = (index: number, field: keyof Header, value: string) => { - setHeaders((prev) => - prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)), - ); - }; + useEffect(() => { + const saved = localStorage.getItem("api_history"); + if (saved) setHistory(JSON.parse(saved)); + }, []); - const removeHeader = (index: number) => { - setHeaders((prev) => prev.filter((_, i) => i !== index)); + const saveHistory = (item: HistoryItem) => { + const updated = [item, ...history].slice(0, 10); + setHistory(updated); + localStorage.setItem("api_history", JSON.stringify(updated)); }; const handleSend = async () => { @@ -51,24 +65,49 @@ export default function ApiTester() { setLoading(true); setResponse(null); - const headerObject = headers.reduce>((acc, h) => { - if (h.key.trim()) { - acc[h.key] = h.value; - } - return acc; - }, {}); - const result = await apiRequest({ method, url, - body: hasBody ? reqBody : undefined, - headers: headerObject, + body: reqBody, + headers, }); setResponse(result); + + saveHistory({ + method, + url, + body: reqBody, + headers, + response: result, + }); + setLoading(false); }; + const addHeader = () => { + setHeaders([...headers, { key: "", value: "" }]); + }; + + const updateHeader = (i: number, key: string, value: string) => { + const copy = [...headers]; + copy[i] = { key, value }; + setHeaders(copy); + }; + + const removeHeader = (i: number) => { + setHeaders(headers.filter((_, idx) => idx !== i)); + }; + + const loadHistory = (item: HistoryItem, index: number) => { + setMethod(item.method); + setUrl(item.url); + setReqBody(item.body); + setHeaders(item.headers); + setResponse(item.response); + setSelectedHistory(index); + }; + const getStatusColor = (status: number) => { if (status >= 200 && status < 300) return "text-success bg-success-bg border-success-border"; @@ -97,135 +136,90 @@ export default function ApiTester() { }; return ( -
- {/* Request Section */} -
-
-
-
+
+
+
+
+
-
- setUrl(e.target.value)} - placeholder="https://api.example.com/v1/users" - className="flex-1 bg-transparent px-4 py-3 font-mono text-primary outline-none" - onKeyDown={(e) => { - if (e.key === "Enter") handleSend(); - }} - /> -
- - -
- - {/* HEADERS */} -
-
-

Headers

- - -
- - {headers.map((h, i) => ( -
updateHeader(i, "key", e.target.value)} - placeholder="Key (e.g. Authorization)" - className="flex-1 bg-background border border-border rounded px-2 py-1 text-sm" + value={url} + onChange={(e) => setUrl(e.target.value)} + className="flex-1 px-3 bg-transparent outline-none font-mono" /> +
- updateHeader(i, "value", e.target.value)} - placeholder="Value" - className="flex-1 bg-background border border-border rounded px-2 py-1 text-sm" - /> + +
+ {/* HEADERS PRESET STYLE */} +
+
+

Headers

- ))} -
- {hasBody && ( -
+
+ {headers.map((h, i) => ( +
+ updateHeader(i, e.target.value, h.value)} + placeholder="key" + className="flex-1 border border-border px-2 py-1 rounded" + /> + updateHeader(i, h.key, e.target.value)} + placeholder="value" + className="flex-1 border border-border px-2 py-1 rounded" + /> + +
+ ))} +
+
+ + {hasBody && ( -
- )} -
- - {/* Response Section */} -
-
-

Response

- - {response && ( -
- - {response.status === 0 - ? "ERROR" - : `${response.status} ${response.statusText}`} - - {response.timeMs} ms -
)}
-
- {!response && !loading && ( -
- Enter a URL and click Send to see the response. -
- )} + {/* RESPONSE */} +
+
+

Response

- {loading && ( -
-
-
- )} + {response && ( + + {response.status} + + )} +
- {response && !loading && ( + {response && (
+
+
+

History

+ + {history.length > 0 && ( + + )} +
+ +
+ {history.length === 0 ? ( +
+ No requests yet. Send a request to see history here. +
+ ) : ( + history.map((h, i) => ( +
loadHistory(h, i)} + className={`p-2 rounded border cursor-pointer text-xs transition ${ + selectedHistory === i + ? "border-primary bg-primary/5" + : "border-border hover:bg-surface/50" + }`} + > +

{h.method}

+

{h.url}

+
+ )) + )} +
+
); } diff --git a/src/utils/apiTester/apiRequest.ts b/src/utils/apiTester/apiRequest.ts index 2131dae..c91cd86 100644 --- a/src/utils/apiTester/apiRequest.ts +++ b/src/utils/apiTester/apiRequest.ts @@ -1,8 +1,4 @@ -export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; - -export type HttpMethod = (typeof HTTP_METHODS)[number]; - -export const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]); +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type Header = { key: string; value: string }; @@ -10,7 +6,7 @@ export interface ApiRequestOptions { method: HttpMethod; url: string; body?: string; - headers?: Record; + headers?: Header[]; } export interface ApiResponse { @@ -21,67 +17,72 @@ export interface ApiResponse { isError: boolean; } +function normalizeHeaders(headers?: Header[]) { + const result: Record = { + Accept: "application/json", + }; + + if (!headers) return result; + + for (const h of headers) { + if (h.key.trim()) { + result[h.key.trim()] = h.value; + } + } + + return result; +} + export async function apiRequest( options: ApiRequestOptions, ): Promise { const { method, url, body, headers } = options; - const startTime = performance.now(); + const start = performance.now(); try { - const hasBody = METHODS_WITH_BODY.has(method); - - const requestHeaders: Record = { - "Content-Type": "application/json", - Accept: "application/json", - ...(headers || {}), - }; + const hasBody = ["POST", "PUT", "PATCH"].includes(method); - const requestOptions: RequestInit = { + const res = await fetch(url, { method, - headers: requestHeaders, - }; - - if (hasBody && body && body.trim()) { - requestOptions.body = body; - } - - const response = await fetch(url, requestOptions); + headers: { + "Content-Type": "application/json", + ...normalizeHeaders(headers), + }, + body: hasBody && body?.trim() ? body : undefined, + }); - const endTime = performance.now(); - const timeMs = Math.round(endTime - startTime); + const end = performance.now(); - let dataStr = ""; + let data = ""; - const contentType = response.headers.get("content-type"); + const contentType = res.headers.get("content-type"); - if (contentType && contentType.includes("application/json")) { + if (contentType?.includes("application/json")) { try { - const json = await response.json(); - dataStr = JSON.stringify(json, null, 2); + data = JSON.stringify(await res.json(), null, 2); } catch { - dataStr = await response.text(); + data = await res.text(); } } else { - dataStr = await response.text(); + data = await res.text(); } return { - status: response.status, - statusText: response.statusText, - timeMs, - data: dataStr, - isError: !response.ok, + status: res.status, + statusText: res.statusText, + timeMs: Math.round(end - start), + data, + isError: !res.ok, }; } catch (err: any) { - const endTime = performance.now(); + const end = performance.now(); return { status: 0, - statusText: "Network Error / CORS Issue", - timeMs: Math.round(endTime - startTime), - data: - err?.message || "Failed to fetch. Check network or CORS configuration.", + statusText: "Network Error", + timeMs: Math.round(end - start), + data: err?.message || "Request failed", isError: true, }; }