diff --git a/.gitignore b/.gitignore index 6ae4e5b..f5e2626 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ yarn-debug.log* yarn-error.log* .bun-debug.log* +# lockfiles (project uses bun) +package-lock.json + # local env files .env*.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..a908693 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# 🦆 Quack API + +A desktop API client built with [Tauri](https://tauri.app/) + [Next.js](https://nextjs.org/) + [React](https://react.dev/). Think Postman, but open-source and native. + +## Features + +### ✅ Implemented + +- **HTTP Requests** — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS with headers, query params, and multiple body types (JSON, text, form-data, x-www-form-urlencoded) +- **WebSocket Support** — Connect, send/receive messages (text & binary), connection status indicators +- **Authentication** — Bearer Token, Basic Auth, and API Key (header or query param) +- **Collections** — Create, organize, and manage collections with folders, drag-and-drop, and YAML-based storage +- **Environment Variables** — Multiple `.env` files per workspace, `{{variable}}` substitution with color-coded status indicators +- **Request History** — Automatically recorded request history with method, URL, status, timing, and clear functionality +- **Response Viewer** — Pretty-printed JSON/XML with syntax highlighting, raw view, headers tab, status codes, timing, and size metrics +- **Workspace Management** — Open folders as workspaces, recent folders, pinned workspaces with emoji support +- **Tabbed Interface** — Multiple request/environment/document tabs with close and reorder +- **Request Settings** — SSL verification toggle and proxy URL support +- **Request Cancellation** — Cancel in-flight requests +- **Collection Docs** — Markdown-based collection descriptions and folder README files + +### 🚧 Planned + +- **Pre-request Scripts** — Execute scripts before sending requests +- **Test Scripts** — Write and run tests against responses +- **Code Generation** — Generate request code snippets in various languages +- **Request Import/Export** — Import from Postman, cURL, OpenAPI +- **Response History** — View past responses for the same request +- **Cookie Management** — Automatic cookie jar handling +- **GraphQL Support** — Schema-aware GraphQL editor +- **gRPC Support** — Protocol buffer-based RPC calls + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Desktop Framework | [Tauri 2](https://tauri.app/) (Rust) | +| Frontend | [Next.js 16](https://nextjs.org/) + [React 19](https://react.dev/) | +| Language | TypeScript (frontend) + Rust (backend) | +| Styling | [Tailwind CSS 4](https://tailwindcss.com/) | +| UI Components | [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) | +| Icons | [Phosphor Icons](https://phosphoricons.com/) + [Lucide](https://lucide.dev/) | +| HTTP Client | [reqwest](https://docs.rs/reqwest/) (Rust, with TLS/gzip/brotli/SOCKS) | +| WebSocket | [tokio-tungstenite](https://docs.rs/tokio-tungstenite/) | +| Storage | [Tauri Store Plugin](https://v2.tauri.app/plugin/store/) + filesystem | +| Drag & Drop | [dnd-kit](https://dndkit.com/) | +| Package Manager | [Bun](https://bun.sh/) | + +## Getting Started + +### Prerequisites + +- [Node.js 22+](https://nodejs.org/) +- [Bun](https://bun.sh/) +- [Rust](https://www.rust-lang.org/tools/install) +- [Tauri CLI prerequisites](https://v2.tauri.app/start/prerequisites/) + +### Development + +```bash +# Install dependencies +bun install + +# Run in development mode (launches Tauri + Next.js) +bun run dev + +# Or run just the Next.js frontend +bun run dev:next +``` + +### Building + +```bash +# Build the desktop application +bun run build + +# Build just the Next.js frontend +bun run build:next +``` + +## Project Structure + +``` +quackAPI/ +├── src/ # Frontend (Next.js/React) +│ ├── app/ # Next.js app router (pages & layout) +│ ├── components/ +│ │ ├── sidebar/ # App sidebar navigation +│ │ ├── ui/ # Base UI components (button, scroll-area, etc.) +│ │ └── workspace/ # Main workspace components +│ ├── context/ # React context (WorkspaceProvider) +│ ├── lib/ # Utilities & Tauri API wrappers +│ │ ├── collections.ts # Collection CRUD operations +│ │ ├── environments.ts # Environment variable management +│ │ ├── http.ts # HTTP request execution +│ │ ├── websocket.ts # WebSocket connections +│ │ ├── settings.ts # App settings & persistence +│ │ ├── types.ts # Shared TypeScript types +│ │ └── utils.ts # Utility functions +│ └── styles/ # Global CSS (Tailwind) +├── src-tauri/ # Desktop backend (Rust) +│ ├── src/ +│ │ ├── lib.rs # Tauri commands & app setup +│ │ └── main.rs # Entry point +│ ├── tauri.conf.json # Tauri configuration +│ └── Cargo.toml # Rust dependencies +├── package.json +├── tsconfig.json +└── next.config.ts +``` + +## License + +This project is open source. diff --git a/src/components/workspace/request-editor.tsx b/src/components/workspace/request-editor.tsx index bd17169..5d46be9 100644 --- a/src/components/workspace/request-editor.tsx +++ b/src/components/workspace/request-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import type { HttpMethod } from "@/lib/types"; +import type { AuthConfig, HistoryEntry, HttpMethod } from "@/lib/types"; import { getRequestDetails, updateRequest, @@ -9,6 +9,7 @@ import { type RequestParamDetail, } from "@/lib/collections"; import { sendHttpRequest, cancelHttpRequest, type HttpResponse } from "@/lib/http"; +import { getAuthConfig, saveAuthConfig } from "@/lib/settings"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import type { EnvFile } from "@/lib/environments"; @@ -224,9 +225,17 @@ interface RequestEditorProps { workspacePath: string | null; environments?: EnvFile[]; onOpenEnvTab?: (envName: string) => void; + onHistoryEntry?: (entry: HistoryEntry) => void; } -export function RequestEditor({ requestId, collectionRelPath, workspacePath, environments, onOpenEnvTab }: RequestEditorProps) { +const AUTH_TYPES: { value: AuthConfig["type"]; label: string }[] = [ + { value: "none", label: "No Auth" }, + { value: "bearer", label: "Bearer Token" }, + { value: "basic", label: "Basic Auth" }, + { value: "apikey", label: "API Key" }, +]; + +export function RequestEditor({ requestId, collectionRelPath, workspacePath, environments, onOpenEnvTab, onHistoryEntry }: RequestEditorProps) { const [details, setDetails] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -239,6 +248,7 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env const [bodyContent, setBodyContent] = useState(""); const [verifySsl, setVerifySsl] = useState(false); const [proxyUrl, setProxyUrl] = useState(""); + const [authConfig, setAuthConfig] = useState({ type: "none" }); const [activeRequestTab, setActiveRequestTab] = useState("params"); const [activeResponseTab, setActiveResponseTab] = useState("pretty"); @@ -300,6 +310,19 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env }; }, [requestId, collectionRelPath, workspacePath]); + // Load auth config when request changes + useEffect(() => { + if (!requestId) { + setAuthConfig({ type: "none" }); + return; + } + let cancelled = false; + getAuthConfig(requestId).then((config) => { + if (!cancelled) setAuthConfig(config); + }); + return () => { cancelled = true; }; + }, [requestId]); + // Debounced save helper const scheduleSave = useCallback( (patch: Record) => { @@ -431,6 +454,48 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env [scheduleSave, verifySsl] ); + const handleAuthChange = useCallback( + (config: AuthConfig) => { + setAuthConfig(config); + if (requestId) { + saveAuthConfig(requestId, config); + } + }, + [requestId] + ); + + /** Convert current auth config into headers / query params for the request. */ + function buildAuthHeaders(config: AuthConfig, sub: (s: string) => string): { key: string; value: string; enabled: boolean }[] { + switch (config.type) { + case "bearer": { + const prefix = config.prefix.trim() || "Bearer"; + return [{ key: "Authorization", value: `${sub(prefix)} ${sub(config.token)}`, enabled: true }]; + } + case "basic": { + const credentials = `${sub(config.username)}:${sub(config.password)}`; + const bytes = new TextEncoder().encode(credentials); + const binary = Array.from(bytes, (b) => String.fromCodePoint(b)).join(""); + const encoded = btoa(binary); + return [{ key: "Authorization", value: `Basic ${encoded}`, enabled: true }]; + } + case "apikey": { + if (config.addTo === "header") { + return [{ key: sub(config.key), value: sub(config.value), enabled: true }]; + } + return []; // query params handled separately + } + default: + return []; + } + } + + function buildAuthParams(config: AuthConfig, sub: (s: string) => string): { key: string; value: string; enabled: boolean }[] { + if (config.type === "apikey" && config.addTo === "query") { + return [{ key: sub(config.key), value: sub(config.value), enabled: true }]; + } + return []; + } + // ── Send request handler ──────────────────────────────────────────── const handleSend = useCallback(async () => { if (!url.trim()) return; @@ -438,6 +503,10 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env const envs = environments ?? []; const sub = (text: string) => substituteEnvVars(text, envs); + // Build auth headers/params + const authHeaders = buildAuthHeaders(authConfig, sub); + const authParams = buildAuthParams(authConfig, sub); + const reqId = crypto.randomUUID(); setCurrentRequestId(reqId); setIsSending(true); @@ -471,22 +540,36 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env } ); + const userHeaders = headers + .filter((h) => h.key.trim() !== "" || h.value.trim() !== "") + .map((h) => ({ key: sub(h.key), value: sub(h.value), enabled: h.enabled })); + const userParams = params + .filter((p) => p.key.trim() !== "" || p.value.trim() !== "") + .map((p) => ({ key: sub(p.key), value: sub(p.value), enabled: p.enabled })); + const result = await sendHttpRequest({ requestId: reqId, method, url: sub(url), - headers: headers - .filter((h) => h.key.trim() !== "" || h.value.trim() !== "") - .map((h) => ({ key: sub(h.key), value: sub(h.value), enabled: h.enabled })), - params: params - .filter((p) => p.key.trim() !== "" || p.value.trim() !== "") - .map((p) => ({ key: sub(p.key), value: sub(p.value), enabled: p.enabled })), + headers: [...userHeaders, ...authHeaders], + params: [...userParams, ...authParams], body: { type: bodyType, content: sub(bodyContent) }, settings: { verifySsl, proxyUrl: proxyUrl.trim() || null }, }); setResponseData(result); setStreamingBody(""); setIsCollapsed(false); + + // Record to history + onHistoryEntry?.({ + id: reqId, + method, + url: sub(url), + status: result.status, + statusText: result.statusText, + timeMs: result.timeMs, + timestamp: new Date().toISOString(), + }); } catch (err: unknown) { let msg: string; if (err instanceof Error) { @@ -502,6 +585,17 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env setResponseData(null); setStreamingBody(""); setIsCollapsed(false); + + // Record failed requests to history too + onHistoryEntry?.({ + id: reqId, + method, + url: sub(url), + status: null, + statusText: null, + timeMs: null, + timestamp: new Date().toISOString(), + }); } finally { if (unlistenProgress) unlistenProgress(); if (unlistenChunk) unlistenChunk(); @@ -509,7 +603,7 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env setCurrentRequestId(null); setProgress(null); } - }, [method, url, headers, params, bodyType, bodyContent, environments, verifySsl, proxyUrl]); + }, [method, url, headers, params, bodyType, bodyContent, environments, verifySsl, proxyUrl, authConfig, onHistoryEntry]); const handleCancel = useCallback(async () => { if (currentRequestId) { @@ -787,8 +881,168 @@ export function RequestEditor({ requestId, collectionRelPath, workspacePath, env )} {activeRequestTab === "auth" && ( -
- Auth configuration will appear here. +
+
+

+ Authorization +

+
+ {AUTH_TYPES.map((at) => ( + + ))} +
+
+ + {authConfig.type === "bearer" && ( +
+
+ + handleAuthChange({ ...authConfig, prefix: e.target.value })} + placeholder="Bearer" + className="w-full rounded-md border border-border bg-transparent px-3 py-1.5 text-[13px] outline-none placeholder:text-muted-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +
+
+ + handleAuthChange({ ...authConfig, token: e.target.value })} + placeholder="Enter token..." + className="w-full rounded-md border border-border bg-transparent px-3 py-1.5 text-[13px] font-mono outline-none placeholder:text-muted-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +

+ The token will be sent as {authConfig.prefix || "Bearer"} <token> in the Authorization header. +

+
+
+ )} + + {authConfig.type === "basic" && ( +
+
+ + handleAuthChange({ ...authConfig, username: e.target.value })} + placeholder="Enter username..." + className="w-full rounded-md border border-border bg-transparent px-3 py-1.5 text-[13px] outline-none placeholder:text-muted-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +
+
+ + handleAuthChange({ ...authConfig, password: e.target.value })} + placeholder="Enter password..." + className="w-full rounded-md border border-border bg-transparent px-3 py-1.5 text-[13px] outline-none placeholder:text-muted-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +

+ Credentials will be Base64-encoded and sent as Basic <encoded> in the Authorization header. +

+
+
+ )} + + {authConfig.type === "apikey" && ( +
+
+ + handleAuthChange({ ...authConfig, key: e.target.value })} + placeholder="e.g. X-API-Key" + className="w-full rounded-md border border-border bg-transparent px-3 py-1.5 text-[13px] outline-none placeholder:text-muted-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +
+
+ + handleAuthChange({ ...authConfig, value: e.target.value })} + placeholder="Enter API key value..." + className="w-full rounded-md border border-border bg-transparent px-3 py-1.5 text-[13px] font-mono outline-none placeholder:text-muted-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> +
+
+ +
+ + +
+
+
+ )} + + {authConfig.type === "none" && ( +

+ This request does not use any authorization. +

+ )}
)} {(activeRequestTab === "pre-req" || activeRequestTab === "tests") && ( diff --git a/src/components/workspace/workspace-view.tsx b/src/components/workspace/workspace-view.tsx index acb624f..187fd80 100644 --- a/src/components/workspace/workspace-view.tsx +++ b/src/components/workspace/workspace-view.tsx @@ -30,7 +30,9 @@ import { updateEnvVariable, type EnvFile } from "@/lib/environments"; -import { Loader2, Plus } from "lucide-react"; +import { addHistoryEntry, clearRequestHistory, getRequestHistory } from "@/lib/settings"; +import type { HistoryEntry } from "@/lib/types"; +import { Loader2, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityBar, type ActivityTab } from "./activity-bar"; import { CollectionDocView } from "./collection-doc-view"; @@ -81,6 +83,28 @@ function InitPrompt() { ); } +const HISTORY_METHOD_COLORS: Record = { + GET: "text-emerald-600", + POST: "text-amber-600", + PUT: "text-blue-600", + PATCH: "text-violet-600", + DELETE: "text-red-600", + HEAD: "text-muted-foreground", + OPTIONS: "text-muted-foreground", +}; + +function formatHistoryTime(iso: string): string { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + function WorkspaceContent() { const { folderPath, refreshFileTree } = useWorkspace(); const [activeActivity, setActiveActivity] = useState("collections"); @@ -88,6 +112,22 @@ function WorkspaceContent() { const [activeTabId, setActiveTabId] = useState(null); const [environments, setEnvironments] = useState([]); const [collections, setCollections] = useState([]); + const [history, setHistory] = useState([]); + + // Load history on mount + useEffect(() => { + getRequestHistory().then(setHistory); + }, []); + + const handleHistoryEntry = useCallback(async (entry: HistoryEntry) => { + const updated = await addHistoryEntry(entry); + setHistory(updated); + }, []); + + const handleClearHistory = useCallback(async () => { + await clearRequestHistory(); + setHistory([]); + }, []); const loadEnvs = useCallback(async () => { if (!folderPath) return; @@ -649,14 +689,59 @@ function WorkspaceContent() { )} {activeActivity === "history" && (
-
+
History + {history.length > 0 && ( + + )}
-
-

No history yet

-
+ {history.length === 0 ? ( +
+

No history yet

+
+ ) : ( +
+ {history.map((entry) => ( + + ))} +
+ )}
)} @@ -753,6 +838,7 @@ function WorkspaceContent() { workspacePath={folderPath} environments={environments} onOpenEnvTab={handleSelectEnv} + onHistoryEntry={handleHistoryEntry} /> )}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts index e2699ce..edbe5f3 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,4 +1,5 @@ import type { Store } from "@tauri-apps/plugin-store"; +import type { AuthConfig, HistoryEntry } from "./types"; export interface RecentFolder { path: string; @@ -41,10 +42,12 @@ const KEYS = { PINNED_WORKSPACES: "pinnedWorkspaces", LAST_SCOPE: "lastScope", SIDEBAR_EXPANDED: "sidebarExpanded", + REQUEST_HISTORY: "requestHistory", } as const; const STORE_FILE = "settings.json"; const MAX_RECENT_FOLDERS = 10; +const MAX_HISTORY_ENTRIES = 50; let storeInstance: Store | null = null; let storeInitPromise: Promise | null = null; @@ -285,3 +288,70 @@ export async function saveSidebarExpanded(expanded: boolean): Promise { await store.save(); } catch {} } + +// ── Request History ───────────────────────────────────────────────────────── + +export async function getRequestHistory(): Promise { + try { + const store = await getStore(); + if (!store) return []; + return (await store.get(KEYS.REQUEST_HISTORY)) ?? []; + } catch { + return []; + } +} + +export async function addHistoryEntry(entry: HistoryEntry): Promise { + try { + const store = await getStore(); + if (!store) return []; + + const existing = + (await store.get(KEYS.REQUEST_HISTORY)) ?? []; + const updated = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES); + await store.set(KEYS.REQUEST_HISTORY, updated); + await store.save(); + return updated; + } catch { + return []; + } +} + +export async function clearRequestHistory(): Promise { + try { + const store = await getStore(); + if (!store) return; + + await store.set(KEYS.REQUEST_HISTORY, []); + await store.save(); + } catch {} +} + +// ── Per-request Auth Config ───────────────────────────────────────────────── + +function authKey(requestId: string): string { + return `auth:${requestId}`; +} + +export async function getAuthConfig(requestId: string): Promise { + try { + const store = await getStore(); + if (!store) return { type: "none" }; + return (await store.get(authKey(requestId))) ?? { type: "none" }; + } catch { + return { type: "none" }; + } +} + +export async function saveAuthConfig( + requestId: string, + config: AuthConfig +): Promise { + try { + const store = await getStore(); + if (!store) return; + + await store.set(authKey(requestId), config); + await store.save(); + } catch {} +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6ba424e..63b782b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -44,3 +44,44 @@ export interface WorkspaceData { environments: Record; activeEnvironment: string | null; } + +// ── Auth types ────────────────────────────────────────────────────────────── + +export type AuthType = "none" | "bearer" | "basic" | "apikey"; + +export interface AuthNone { + type: "none"; +} + +export interface AuthBearer { + type: "bearer"; + token: string; + prefix: string; // defaults to "Bearer" +} + +export interface AuthBasic { + type: "basic"; + username: string; + password: string; +} + +export interface AuthApiKey { + type: "apikey"; + key: string; + value: string; + addTo: "header" | "query"; +} + +export type AuthConfig = AuthNone | AuthBearer | AuthBasic | AuthApiKey; + +// ── History types ─────────────────────────────────────────────────────────── + +export interface HistoryEntry { + id: string; + method: HttpMethod; + url: string; + status: number | null; + statusText: string | null; + timeMs: number | null; + timestamp: string; // ISO 8601 +}