diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 81a852a..81f7ad3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -673,6 +673,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -1125,7 +1131,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -1782,8 +1788,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1805,9 +1813,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1964,6 +1974,7 @@ dependencies = [ "image", "lazy_static", "objc", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -2168,6 +2179,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2827,6 +2839,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3935,6 +3953,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -4002,6 +4076,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -4162,6 +4245,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -4318,6 +4439,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -4365,6 +4487,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4593,6 +4721,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -5052,7 +5192,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -5260,7 +5400,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -6168,6 +6308,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -6233,6 +6383,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ca8b0e6..6595449 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } chrono = "0.4" lazy_static = "1.5" image = "0.25" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a891412..9242c9b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1029,3 +1029,64 @@ mod tests { assert_eq!(out.get_pixel(3, 3), &image::Rgba([5, 5, 0, 255])); } } + +// ---- HTTP request proxy (used by the API Client tool) ---- + +#[derive(serde::Serialize)] +pub struct HttpResponse { + pub status: u16, + pub status_text: String, + pub headers: std::collections::HashMap, + pub body: String, + pub duration_ms: u64, +} + +#[tauri::command] +pub async fn http_request( + method: String, + url: String, + headers: std::collections::HashMap, + body: Option, +) -> Result { + let start = std::time::Instant::now(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(false) + .redirect(reqwest::redirect::Policy::limited(10)) + .build() + .map_err(|e| e.to_string())?; + + let method_parsed = reqwest::Method::from_bytes(method.to_uppercase().as_bytes()) + .map_err(|e| e.to_string())?; + + let mut builder = client.request(method_parsed, &url); + for (k, v) in &headers { + if let (Ok(name), Ok(val)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(v), + ) { + builder = builder.header(name, val); + } + } + if let Some(b) = body { + builder = builder.body(b); + } + + let res = builder.send().await.map_err(|e| e.to_string())?; + let duration_ms = start.elapsed().as_millis() as u64; + let status = res.status().as_u16(); + let status_text = res.status().canonical_reason().unwrap_or("").to_string(); + let mut resp_headers = std::collections::HashMap::new(); + for (k, v) in res.headers() { + if let Ok(val) = v.to_str() { + resp_headers.insert(k.as_str().to_string(), val.to_string()); + } + } + let body_str = res.text().await.map_err(|e| e.to_string())?; + Ok(HttpResponse { + status, + status_text, + headers: resp_headers, + body: body_str, + duration_ms, + }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1c8f488..9ca0772 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -50,6 +50,7 @@ fn main() { commands::check_permissions, commands::mark_first_run_complete, commands::open_system_preferences, + commands::http_request, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/islands/dev/ApiClient.tsx b/src/islands/dev/ApiClient.tsx new file mode 100644 index 0000000..a975fd4 --- /dev/null +++ b/src/islands/dev/ApiClient.tsx @@ -0,0 +1,690 @@ +import { useEffect, useRef, useState } from 'react'; +import { Check, Upload, Download, Plus, Folder, History, Key, Trash2, ChevronDown, ChevronRight, Send, RefreshCw, X } from 'lucide-react'; +import { isTauri } from '@/services/platform'; +import { loadWorkspace, saveWorkspace, defaultRequestDef, pushResponseToRequest, addToHistory } from '@/tools/dev/api-client-store.lib'; +import { substituteRequest, applyCapture } from '@/tools/dev/api-client-env.lib'; +import { executeRequest } from '@/tools/dev/api-client-request.lib'; +import { detectAndParse } from '@/tools/dev/api-client-import.lib'; +import { exportPostman, exportWorkspace } from '@/tools/dev/api-client-export.lib'; +import { downloadService } from '@/services/download'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import type { Workspace, RequestDef, Collection, Folder as FolderType, Environment, HistoryEntry } from '@/tools/dev/api-client.types'; +import { SAVE_INTERVAL } from '@/tools/dev/api-client.types'; + +// ---- helpers ---- + +function findRequest(col: { requests: RequestDef[]; folders: FolderType[] }, id: string): RequestDef | null { + for (const r of col.requests) { if (r.id === id) return r; } + for (const f of col.folders) { + const found = findRequest(f, id); + if (found) return found; + } + return null; +} + +function allRequestsInCol(col: { requests: RequestDef[]; folders: FolderType[] }): RequestDef[] { + return [...col.requests, ...col.folders.flatMap(f => allRequestsInCol(f))]; +} + +function allRequests(w: Workspace): RequestDef[] { + return w.collections.flatMap(c => allRequestsInCol(c)); +} + +function updateReqInCol(col: Collection, updated: RequestDef): Collection { + return { + ...col, + requests: col.requests.map(r => r.id === updated.id ? updated : r), + folders: col.folders.map(f => ({ + ...f, + requests: f.requests.map(r => r.id === updated.id ? updated : r), + folders: f.folders.map(sf => ({ ...sf, requests: sf.requests.map(r => r.id === updated.id ? updated : r) })), + })), + }; +} + +function updateRequestInWorkspace(w: Workspace, updated: RequestDef): Workspace { + return { ...w, collections: w.collections.map(c => updateReqInCol(c, updated)) }; +} + +function addRequestToCollection(w: Workspace, colId: string, req: RequestDef): Workspace { + return { + ...w, + collections: w.collections.map(c => c.id === colId ? { ...c, requests: [...c.requests, req] } : c), + activeRequestId: req.id, + }; +} + +// ---- Main island ---- + +export default function ApiClient() { + const [workspace, setWorkspace] = useState(() => { + if (typeof window === 'undefined') { + return { collections: [], envs: [], activeEnvId: null, activeCollectionId: null, activeRequestId: null, lastResponse: null, history: [] }; + } + return loadWorkspace(); + }); + + const [countdown, setCountdown] = useState(0); + const dirty = useRef(false); + const latestWorkspace = useRef(workspace); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const isDesktop = typeof window !== 'undefined' && isTauri(); + + const mutate = (updater: (w: Workspace) => Workspace) => { + setWorkspace(prev => { + const next = updater(prev); + latestWorkspace.current = next; + if (!dirty.current) { dirty.current = true; setCountdown(SAVE_INTERVAL); } + return next; + }); + }; + + const flushSave = () => { + if (!dirty.current) return; + dirty.current = false; + setCountdown(0); + saveWorkspace(latestWorkspace.current); + }; + + useEffect(() => { + const tick = setInterval(() => { + if (!dirty.current) return; + setCountdown(c => { + if (c <= 1) { flushSave(); return 0; } + return c - 1; + }); + }, 1000); + const onHide = () => { if (document.visibilityState === 'hidden') flushSave(); }; + const onBeforeUnload = (e: BeforeUnloadEvent) => { if (dirty.current) { e.preventDefault(); e.returnValue = ''; } }; + document.addEventListener('visibilitychange', onHide); + window.addEventListener('pagehide', flushSave); + window.addEventListener('beforeunload', onBeforeUnload); + return () => { + clearInterval(tick); + document.removeEventListener('visibilitychange', onHide); + window.removeEventListener('pagehide', flushSave); + window.removeEventListener('beforeunload', onBeforeUnload); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const activeEnv = workspace.envs.find(e => e.id === workspace.activeEnvId) ?? null; + const envVars = activeEnv?.vars ?? {}; + + const activeRequest: RequestDef | null = (() => { + if (!workspace.activeRequestId) return null; + for (const col of workspace.collections) { + const found = findRequest(col, workspace.activeRequestId); + if (found) return found; + } + return null; + })(); + + const handleImport = async (file: File) => { + try { + const text = await file.text(); + const col = detectAndParse(text, file.name); + mutate(w => ({ ...w, collections: [...w.collections, col], activeCollectionId: col.id })); + } catch (err) { + setSendError(`Import failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + const handleSend = async () => { + if (!activeRequest) return; + setSending(true); + setSendError(null); + try { + const allReqs = allRequests(workspace); + const substituted = substituteRequest(activeRequest, allReqs, envVars); + const res = await executeRequest(substituted); + mutate(w => { + const updatedReq = pushResponseToRequest(activeRequest, res); + let updatedW = updateRequestInWorkspace(w, updatedReq); + if (activeRequest.capture && activeEnv) { + const newVars = applyCapture(activeRequest.capture, res.body, envVars); + updatedW = { ...updatedW, envs: updatedW.envs.map(e => e.id === activeEnv.id ? { ...e, vars: newVars } : e) }; + } + const entry: HistoryEntry = { id: crypto.randomUUID(), ts: Date.now(), req: substituted, res }; + return addToHistory({ ...updatedW, lastResponse: res }, entry); + }); + } catch (err) { + setSendError(err instanceof Error ? err.message : String(err)); + } finally { + setSending(false); + } + }; + + const statusChip = countdown > 0 + ? ( + + ) : ( + + Saved + + ); + + return ( +
+ {/* Toolbar */} +
+ + +
+ {!isDesktop && ( + + Browser mode — CORS restrictions apply. Desktop app bypasses CORS. + + )} + {statusChip} +
+
+ + {/* Two-column layout */} +
+ mutate(w => ({ ...w, activeRequestId: id }))} + onNewRequest={() => { + const req = defaultRequestDef(); + mutate(w => { + if (!w.activeCollectionId) { + // Create a default collection if none exists + const col = { id: crypto.randomUUID(), name: 'My Collection', folders: [], requests: [req] }; + return { ...w, collections: [...w.collections, col], activeCollectionId: col.id, activeRequestId: req.id }; + } + return addRequestToCollection(w, w.activeCollectionId, req); + }); + }} + onSelectCollection={id => mutate(w => ({ ...w, activeCollectionId: id }))} + onSelectEnv={id => mutate(w => ({ ...w, activeEnvId: id || null }))} + onDeleteCollection={id => mutate(w => ({ + ...w, + collections: w.collections.filter(c => c.id !== id), + activeCollectionId: w.activeCollectionId === id ? null : w.activeCollectionId, + }))} + onExportCollection={(col) => { + const blob = new Blob([exportPostman(col, workspace.envs)], { type: 'application/json' }); + downloadService.download(blob, `${col.name.replace(/\s+/g, '-')}-postman.json`); + }} + onAddEnv={() => { + const env: Environment = { id: crypto.randomUUID(), name: 'New Environment', vars: {} }; + mutate(w => ({ ...w, envs: [...w.envs, env], activeEnvId: env.id })); + }} + onUpdateEnvVars={(id, vars) => mutate(w => ({ ...w, envs: w.envs.map(e => e.id === id ? { ...e, vars } : e) }))} + /> + + {/* Right pane */} +
+ {activeRequest ? ( + mutate(w => updateRequestInWorkspace(w, req))} + sendError={sendError} + allReqs={allRequests(workspace)} + envVars={envVars} + /> + ) : ( +
+ Import a collection or click "+ New request" to get started. +
+ )} +
+
+
+ ); +} + +// ---- ImportButton ---- + +function ImportButton({ onImport }: { onImport: (f: File) => void }) { + const ref = useRef(null); + return ( + <> + { const f = e.target.files?.[0]; if (f) { onImport(f); e.target.value = ''; } }} /> + + + ); +} + +// ---- ApiSidebar ---- + +function ApiSidebar({ + workspace, onSelectRequest, onNewRequest, onSelectCollection, + onSelectEnv, onDeleteCollection, onExportCollection, onAddEnv, onUpdateEnvVars, +}: { + workspace: Workspace; + onSelectRequest: (id: string) => void; + onNewRequest: () => void; + onSelectCollection: (id: string) => void; + onSelectEnv: (id: string) => void; + onDeleteCollection: (id: string) => void; + onExportCollection: (col: Collection) => void; + onAddEnv: () => void; + onUpdateEnvVars: (id: string, vars: Record) => void; +}) { + const [tab, setTab] = useState<'collections' | 'history'>('collections'); + const [openCols, setOpenCols] = useState>(new Set()); + const [editingEnvId, setEditingEnvId] = useState(null); + const [envDraft, setEnvDraft] = useState(''); + + const toggleCol = (id: string) => setOpenCols(prev => { + const s = new Set(prev); + if (s.has(id)) s.delete(id); else s.add(id); + return s; + }); + + const activeEnv = workspace.envs.find(e => e.id === workspace.activeEnvId); + + const startEditEnv = () => { + if (!activeEnv) return; + setEnvDraft(Object.entries(activeEnv.vars).map(([k, v]) => `${k}=${v}`).join('\n')); + setEditingEnvId(activeEnv.id); + }; + + const saveEnvDraft = () => { + if (!editingEnvId) return; + const vars: Record = {}; + envDraft.split('\n').forEach(line => { + const eq = line.indexOf('='); + if (eq > 0) vars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + }); + onUpdateEnvVars(editingEnvId, vars); + setEditingEnvId(null); + }; + + return ( +
+ {/* Tabs */} +
+ {(['collections', 'history'] as const).map(t => ( + + ))} +
+ + {tab === 'collections' && ( +
+
+ +
+ {workspace.collections.length === 0 && ( +

Import a collection to get started.

+ )} + {workspace.collections.map(col => ( +
+
{ toggleCol(col.id); onSelectCollection(col.id); }}> + {openCols.has(col.id) ? : } + {col.name} + + +
+ {openCols.has(col.id) && ( + + )} +
+ ))} +
+ )} + + {tab === 'history' && ( +
+ {workspace.history.length === 0 + ?

No requests sent yet.

+ : workspace.history.map(entry => ( + + ))} +
+ )} + + {/* Environment panel */} +
+
+ Env +
+
+ + + {activeEnv && ( + + )} +
+ {editingEnvId && activeEnv && ( +
+

One KEY=value per line

+