From 001c2ac1fdbe184d255c3634b507b3eaad8145b8 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:17:36 +0700 Subject: [PATCH 1/9] feat(api-client): add shared TypeScript types --- src/tools/dev/api-client.types.ts | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/tools/dev/api-client.types.ts diff --git a/src/tools/dev/api-client.types.ts b/src/tools/dev/api-client.types.ts new file mode 100644 index 0000000..4bcbff0 --- /dev/null +++ b/src/tools/dev/api-client.types.ts @@ -0,0 +1,86 @@ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; + +export type KV = { key: string; value: string; enabled: boolean }; + +export type AuthDef = + | { type: 'none' } + | { type: 'bearer'; token: string } + | { type: 'basic'; username: string; password: string } + | { type: 'api-key'; header: string; value: string }; + +export type BodyDef = + | { mode: 'none' } + | { mode: 'json'; content: string } + | { mode: 'form'; fields: KV[] } + | { mode: 'raw'; content: string; contentType: string }; + +export type CaptureRule = { jsonPath: string; intoVar: string }; + +export type VarSource = + | { type: 'env'; varName: string } + | { type: 'response'; requestId: string; jsonPath: string }; + +export type VarBinding = { name: string; source: VarSource }; + +export type ResponseSnapshot = { + status: number; + statusText: string; + headers: Record; + body: string; + durationMs: number; +}; + +export const MAX_REQUEST_RESPONSES = 5; +export const MAX_HISTORY = 50; +export const SAVE_INTERVAL = 30; + +export type RequestDef = { + id: string; + name: string; + method: HttpMethod; + url: string; + params: KV[]; + headers: KV[]; + body: BodyDef; + auth: AuthDef; + capture: CaptureRule | null; + bindings: VarBinding[]; + responseHistory: ResponseSnapshot[]; +}; + +export type Folder = { + id: string; + name: string; + folders: Folder[]; + requests: RequestDef[]; +}; + +export type Collection = { + id: string; + name: string; + folders: Folder[]; + requests: RequestDef[]; +}; + +export type Environment = { + id: string; + name: string; + vars: Record; +}; + +export type HistoryEntry = { + id: string; + ts: number; + req: RequestDef; + res: ResponseSnapshot; +}; + +export type Workspace = { + collections: Collection[]; + envs: Environment[]; + activeEnvId: string | null; + activeCollectionId: string | null; + activeRequestId: string | null; + lastResponse: ResponseSnapshot | null; + history: HistoryEntry[]; +}; From b0d545074abf07478c8647d2122e106db12ccc1f Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:18:17 +0700 Subject: [PATCH 2/9] feat(api-client): add store lib with localStorage persistence and ring buffers --- src/tools/dev/api-client-store.lib.test.ts | 68 ++++++++++++++++++++++ src/tools/dev/api-client-store.lib.ts | 62 ++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/tools/dev/api-client-store.lib.test.ts create mode 100644 src/tools/dev/api-client-store.lib.ts diff --git a/src/tools/dev/api-client-store.lib.test.ts b/src/tools/dev/api-client-store.lib.test.ts new file mode 100644 index 0000000..0110a34 --- /dev/null +++ b/src/tools/dev/api-client-store.lib.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + defaultWorkspace, defaultRequestDef, + pushResponseToRequest, addToHistory, + loadWorkspace, saveWorkspace, STORAGE_KEY, +} from './api-client-store.lib'; +import type { ResponseSnapshot, HistoryEntry } from './api-client.types'; +import { MAX_REQUEST_RESPONSES, MAX_HISTORY } from './api-client.types'; + +const snap = (status: number): ResponseSnapshot => ({ + status, statusText: 'OK', headers: {}, body: '{}', durationMs: 10, +}); + +describe('pushResponseToRequest', () => { + it('prepends response and keeps newest at index 0', () => { + const req = defaultRequestDef(); + const r1 = pushResponseToRequest(req, snap(200)); + expect(r1.responseHistory[0].status).toBe(200); + const r2 = pushResponseToRequest(r1, snap(201)); + expect(r2.responseHistory[0].status).toBe(201); + expect(r2.responseHistory[1].status).toBe(200); + }); + + it(`caps at MAX_REQUEST_RESPONSES (${MAX_REQUEST_RESPONSES})`, () => { + let req = defaultRequestDef(); + for (let i = 0; i < MAX_REQUEST_RESPONSES + 2; i++) { + req = pushResponseToRequest(req, snap(200 + i)); + } + expect(req.responseHistory.length).toBe(MAX_REQUEST_RESPONSES); + }); +}); + +describe('addToHistory', () => { + it('prepends entry and caps at MAX_HISTORY', () => { + let w = defaultWorkspace(); + const req = defaultRequestDef(); + for (let i = 0; i < MAX_HISTORY + 5; i++) { + const entry: HistoryEntry = { id: String(i), ts: i, req, res: snap(200) }; + w = addToHistory(w, entry); + } + expect(w.history.length).toBe(MAX_HISTORY); + expect(w.history[0].id).toBe(String(MAX_HISTORY + 4)); + }); +}); + +describe('loadWorkspace / saveWorkspace', () => { + beforeEach(() => { localStorage.clear(); }); + + it('returns default workspace when nothing stored', () => { + const w = loadWorkspace(); + expect(w.collections).toEqual([]); + expect(w.history).toEqual([]); + }); + + it('round-trips through localStorage', () => { + const w = defaultWorkspace(); + w.envs.push({ id: 'e1', name: 'dev', vars: { token: 'abc' } }); + saveWorkspace(w); + const loaded = loadWorkspace(); + expect(loaded.envs[0].vars.token).toBe('abc'); + }); + + it('returns default on corrupted localStorage', () => { + localStorage.setItem(STORAGE_KEY, 'not-json{{{'); + const w = loadWorkspace(); + expect(w.collections).toEqual([]); + }); +}); diff --git a/src/tools/dev/api-client-store.lib.ts b/src/tools/dev/api-client-store.lib.ts new file mode 100644 index 0000000..d6e9061 --- /dev/null +++ b/src/tools/dev/api-client-store.lib.ts @@ -0,0 +1,62 @@ +import type { RequestDef, Workspace, ResponseSnapshot, HistoryEntry } from './api-client.types'; +import { MAX_REQUEST_RESPONSES, MAX_HISTORY } from './api-client.types'; + +export const STORAGE_KEY = 'gwt.api-client'; + +export function defaultRequestDef(): RequestDef { + return { + id: crypto.randomUUID(), + name: 'New Request', + method: 'GET', + url: '', + params: [], + headers: [], + body: { mode: 'none' }, + auth: { type: 'none' }, + capture: null, + bindings: [], + responseHistory: [], + }; +} + +export function defaultWorkspace(): Workspace { + return { + collections: [], + envs: [], + activeEnvId: null, + activeCollectionId: null, + activeRequestId: null, + lastResponse: null, + history: [], + }; +} + +export function pushResponseToRequest(req: RequestDef, res: ResponseSnapshot): RequestDef { + return { + ...req, + responseHistory: [res, ...req.responseHistory].slice(0, MAX_REQUEST_RESPONSES), + }; +} + +export function addToHistory(w: Workspace, entry: HistoryEntry): Workspace { + return { + ...w, + history: [entry, ...w.history].slice(0, MAX_HISTORY), + }; +} + +export function loadWorkspace(): Workspace { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultWorkspace(); + return JSON.parse(raw) as Workspace; + } catch { + return defaultWorkspace(); + } +} + +export function saveWorkspace(w: Workspace): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(w)); + } catch { /* quota exceeded */ } +} From ee9e8fc194f0cf34f1ae6fe29030b039bb808067 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:19:36 +0700 Subject: [PATCH 3/9] feat(api-client): add import parsers (Postman/Insomnia/OpenAPI/HAR) --- src/tools/dev/api-client-import.lib.test.ts | 109 ++++++++++ src/tools/dev/api-client-import.lib.ts | 228 ++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 src/tools/dev/api-client-import.lib.test.ts create mode 100644 src/tools/dev/api-client-import.lib.ts diff --git a/src/tools/dev/api-client-import.lib.test.ts b/src/tools/dev/api-client-import.lib.test.ts new file mode 100644 index 0000000..ae5fc01 --- /dev/null +++ b/src/tools/dev/api-client-import.lib.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { detectAndParse } from './api-client-import.lib'; + +const postmanV21 = JSON.stringify({ + info: { name: 'My API', schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, + item: [ + { name: 'Login', request: { method: 'POST', url: { raw: 'https://api.example.com/login' }, header: [{ key: 'Content-Type', value: 'application/json', disabled: false }], body: { mode: 'raw', raw: '{"email":"{{email}}"}' } } }, + { name: 'Users', item: [ + { name: 'List Users', request: { method: 'GET', url: 'https://api.example.com/users', header: [] } }, + ]}, + ], +}); + +const insomniaV4 = JSON.stringify({ + __export_format: 4, + resources: [ + { _id: 'wrk_1', _type: 'workspace', name: 'My Workspace' }, + { _id: 'fld_1', _type: 'request_group', parentId: 'wrk_1', name: 'Auth' }, + { _id: 'req_1', _type: 'request', parentId: 'fld_1', name: 'Login', method: 'POST', url: 'https://api.example.com/login', headers: [{ name: 'Content-Type', value: 'application/json' }], body: { mimeType: 'application/json', text: '{"email":"test"}' } }, + ], +}); + +const openApi2 = JSON.stringify({ + swagger: '2.0', info: { title: 'My API', version: '1.0' }, + host: 'api.example.com', basePath: '/v1', + paths: { + '/users': { get: { operationId: 'listUsers', summary: 'List users', parameters: [] } }, + '/users/{id}': { delete: { operationId: 'deleteUser', summary: 'Delete user', parameters: [] } }, + }, +}); + +const openApi3 = JSON.stringify({ + openapi: '3.0.0', info: { title: 'My API', version: '1.0' }, + servers: [{ url: 'https://api.example.com/v1' }], + paths: { + '/items': { post: { operationId: 'createItem', summary: 'Create item', requestBody: { content: { 'application/json': { schema: {} } } } } }, + }, +}); + +const har = JSON.stringify({ + log: { + version: '1.2', + entries: [ + { request: { method: 'GET', url: 'https://api.example.com/users', headers: [{ name: 'Authorization', value: 'Bearer token' }], postData: null } }, + { request: { method: 'POST', url: 'https://api.example.com/users', headers: [], postData: { mimeType: 'application/json', text: '{"name":"Alice"}' } } }, + ], + }, +}); + +describe('detectAndParse — Postman v2.1', () => { + it('parses flat requests', () => { + const col = detectAndParse(postmanV21, 'collection.json'); + expect(col.requests.find(r => r.name === 'Login')?.method).toBe('POST'); + expect(col.requests.find(r => r.name === 'Login')?.url).toBe('https://api.example.com/login'); + expect(col.requests.find(r => r.name === 'Login')?.headers[0].key).toBe('Content-Type'); + }); + it('parses nested folder', () => { + const col = detectAndParse(postmanV21, 'collection.json'); + const folder = col.folders.find(f => f.name === 'Users'); + expect(folder?.requests[0].name).toBe('List Users'); + }); +}); + +describe('detectAndParse — Insomnia v4', () => { + it('parses request inside group', () => { + const col = detectAndParse(insomniaV4, 'insomnia.json'); + const folder = col.folders.find(f => f.name === 'Auth'); + expect(folder?.requests[0].method).toBe('POST'); + expect(folder?.requests[0].url).toBe('https://api.example.com/login'); + }); +}); + +describe('detectAndParse — OpenAPI 2', () => { + it('generates one request per operation', () => { + const col = detectAndParse(openApi2, 'swagger.json'); + const all = [...col.requests, ...col.folders.flatMap(f => f.requests)]; + expect(all.length).toBe(2); + const list = all.find(r => r.method === 'GET'); + expect(list?.url).toContain('api.example.com'); + }); +}); + +describe('detectAndParse — OpenAPI 3', () => { + it('uses servers[0].url as base', () => { + const col = detectAndParse(openApi3, 'openapi.json'); + const all = [...col.requests, ...col.folders.flatMap(f => f.requests)]; + expect(all[0].url).toContain('api.example.com/v1/items'); + }); +}); + +describe('detectAndParse — HAR', () => { + it('imports all entries as requests', () => { + const col = detectAndParse(har, 'archive.har'); + expect(col.requests.length).toBe(2); + expect(col.requests[1].body).toMatchObject({ mode: 'json' }); + }); + it('maps HAR headers', () => { + const col = detectAndParse(har, 'archive.har'); + expect(col.requests[0].headers[0]).toMatchObject({ key: 'Authorization', value: 'Bearer token', enabled: true }); + }); +}); + +describe('detectAndParse — YAML OpenAPI 3', () => { + it('parses yaml extension as OpenAPI 3', () => { + const yamlStr = `openapi: "3.0.0"\ninfo:\n title: My API\n version: "1"\nservers:\n - url: https://api.example.com\npaths:\n /ping:\n get:\n operationId: ping\n summary: Ping\n`; + const col = detectAndParse(yamlStr, 'openapi.yaml'); + expect(col.requests[0].url).toContain('/ping'); + }); +}); diff --git a/src/tools/dev/api-client-import.lib.ts b/src/tools/dev/api-client-import.lib.ts new file mode 100644 index 0000000..4c16f71 --- /dev/null +++ b/src/tools/dev/api-client-import.lib.ts @@ -0,0 +1,228 @@ +import { parse as parseYaml } from 'yaml'; +import { defaultRequestDef } from './api-client-store.lib'; +import type { Collection, Folder, RequestDef, KV, BodyDef } from './api-client.types'; + +function uuid(): string { + return crypto.randomUUID(); +} + +function kv(key: string, value: string, enabled = true): KV { + return { key, value, enabled }; +} + +// ---- Postman v2.1 ---- + +function postmanUrl(url: unknown): string { + if (typeof url === 'string') return url; + if (url && typeof url === 'object' && 'raw' in url) return (url as { raw: string }).raw; + return ''; +} + +function postmanBody(body: unknown): BodyDef { + if (!body || typeof body !== 'object') return { mode: 'none' }; + const b = body as Record; + if (b.mode === 'raw') return { mode: 'json', content: String(b.raw ?? '') }; + if (b.mode === 'urlencoded') { + const fields = Array.isArray(b.urlencoded) + ? (b.urlencoded as Array<{ key: string; value: string; disabled?: boolean }>).map(f => kv(f.key, f.value, !f.disabled)) + : []; + return { mode: 'form', fields }; + } + return { mode: 'none' }; +} + +function parsePostmanItem(item: unknown): { requests: RequestDef[]; folders: Folder[] } { + const requests: RequestDef[] = []; + const folders: Folder[] = []; + if (!Array.isArray(item)) return { requests, folders }; + for (const entry of item) { + const e = entry as Record; + if (Array.isArray(e.item)) { + const sub = parsePostmanItem(e.item); + folders.push({ id: uuid(), name: String(e.name ?? 'Folder'), folders: sub.folders, requests: sub.requests }); + } else if (e.request && typeof e.request === 'object') { + const req = e.request as Record; + const headers = Array.isArray(req.header) + ? (req.header as Array<{ key: string; value: string; disabled?: boolean }>).map(h => kv(h.key, h.value, !h.disabled)) + : []; + requests.push({ + ...defaultRequestDef(), + id: uuid(), + name: String(e.name ?? 'Request'), + method: String(req.method ?? 'GET').toUpperCase() as RequestDef['method'], + url: postmanUrl(req.url), + headers, + body: postmanBody(req.body), + }); + } + } + return { requests, folders }; +} + +function parsePostman(data: unknown): Collection { + const d = data as Record; + const name = String((d.info as Record)?.name ?? 'Postman Collection'); + const { requests, folders } = parsePostmanItem(d.item); + return { id: uuid(), name, folders, requests }; +} + +// ---- Insomnia v4 ---- + +function parseInsomnia(data: unknown): Collection { + const d = data as Record; + const resources = Array.isArray(d.resources) ? (d.resources as Array>) : []; + const workspace = resources.find(r => r._type === 'workspace'); + const name = String(workspace?.name ?? 'Insomnia Workspace'); + + const groupMap = new Map(); + const rootRequests: RequestDef[] = []; + + for (const r of resources) { + if (r._type === 'request_group') { + groupMap.set(String(r._id), { id: uuid(), name: String(r.name ?? 'Folder'), folders: [], requests: [] }); + } + } + + for (const r of resources) { + if (r._type !== 'request') continue; + const headers = Array.isArray(r.headers) + ? (r.headers as Array<{ name: string; value: string }>).map(h => kv(h.name, h.value)) + : []; + const bodyRaw = r.body as Record | undefined; + let body: BodyDef = { mode: 'none' }; + if (bodyRaw?.mimeType === 'application/json' && bodyRaw.text) { + body = { mode: 'json', content: String(bodyRaw.text) }; + } else if (bodyRaw?.mimeType === 'application/x-www-form-urlencoded') { + const fields = Array.isArray(bodyRaw.params) + ? (bodyRaw.params as Array<{ name: string; value: string }>).map(p => kv(p.name, p.value)) + : []; + body = { mode: 'form', fields }; + } + const def: RequestDef = { + ...defaultRequestDef(), + id: uuid(), name: String(r.name ?? 'Request'), + method: String(r.method ?? 'GET').toUpperCase() as RequestDef['method'], + url: String(r.url ?? ''), headers, body, + }; + const parentId = String(r.parentId ?? ''); + const folder = groupMap.get(parentId); + if (folder) folder.requests.push(def); + else rootRequests.push(def); + } + + return { id: uuid(), name, folders: Array.from(groupMap.values()), requests: rootRequests }; +} + +// ---- OpenAPI 2 ---- + +function parseOpenApi2(data: unknown): Collection { + const d = data as Record; + const info = d.info as Record; + const name = String(info?.title ?? 'Swagger Collection'); + const base = `https://${d.host ?? 'example.com'}${d.basePath ?? ''}`; + const paths = (d.paths ?? {}) as Record>; + const requests: RequestDef[] = []; + for (const [path, methods] of Object.entries(paths)) { + for (const [method, op] of Object.entries(methods)) { + const operation = op as Record; + requests.push({ + ...defaultRequestDef(), + id: uuid(), + name: String(operation.summary ?? operation.operationId ?? `${method.toUpperCase()} ${path}`), + method: method.toUpperCase() as RequestDef['method'], + url: base + path, + }); + } + } + return { id: uuid(), name, folders: [], requests }; +} + +// ---- OpenAPI 3 ---- + +function parseOpenApi3(data: unknown): Collection { + const d = data as Record; + const info = d.info as Record; + const name = String(info?.title ?? 'OpenAPI Collection'); + const servers = Array.isArray(d.servers) ? (d.servers as Array<{ url: string }>) : []; + const base = servers[0]?.url ?? ''; + const paths = (d.paths ?? {}) as Record>; + const requests: RequestDef[] = []; + for (const [path, methods] of Object.entries(paths)) { + for (const [method, op] of Object.entries(methods)) { + const operation = op as Record; + const hasBody = ['post', 'put', 'patch'].includes(method.toLowerCase()); + const body: BodyDef = hasBody ? { mode: 'json', content: '{}' } : { mode: 'none' }; + requests.push({ + ...defaultRequestDef(), + id: uuid(), + name: String(operation.summary ?? operation.operationId ?? `${method.toUpperCase()} ${path}`), + method: method.toUpperCase() as RequestDef['method'], + url: base + path, + body, + }); + } + } + return { id: uuid(), name, folders: [], requests }; +} + +// ---- HAR ---- + +function parseHar(data: unknown): Collection { + const d = data as Record; + const log = d.log as Record; + const entries = Array.isArray(log?.entries) ? (log.entries as Array>) : []; + const requests: RequestDef[] = entries.map(entry => { + const req = entry.request as Record; + const headers = Array.isArray(req.headers) + ? (req.headers as Array<{ name: string; value: string }>).map(h => kv(h.name, h.value)) + : []; + const pd = req.postData as Record | null | undefined; + let body: BodyDef = { mode: 'none' }; + if (pd?.text) { + const mime = String(pd.mimeType ?? ''); + if (mime.includes('json')) body = { mode: 'json', content: String(pd.text) }; + else if (mime.includes('form')) { + const fields = Array.isArray(pd.params) + ? (pd.params as Array<{ name: string; value: string }>).map(p => kv(p.name, p.value)) + : []; + body = { mode: 'form', fields }; + } else { + body = { mode: 'raw', content: String(pd.text), contentType: mime }; + } + } + const rawUrl = String(req.url ?? ''); + const urlObj = (() => { try { return new URL(rawUrl); } catch { return null; } })(); + const params: KV[] = urlObj + ? Array.from(urlObj.searchParams.entries()).map(([k, v]) => kv(k, v)) + : []; + return { + ...defaultRequestDef(), + id: uuid(), + name: `${String(req.method ?? 'GET')} ${rawUrl.split('?')[0].split('/').pop() ?? rawUrl}`, + method: String(req.method ?? 'GET').toUpperCase() as RequestDef['method'], + url: urlObj ? `${urlObj.origin}${urlObj.pathname}` : rawUrl, + params, headers, body, + }; + }); + return { id: uuid(), name: 'HAR Archive', folders: [], requests }; +} + +// ---- Auto-detect ---- + +export function detectAndParse(raw: string, filename: string): Collection { + const lower = filename.toLowerCase(); + let data: unknown; + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) { + data = parseYaml(raw); + } else { + data = JSON.parse(raw); + } + const d = data as Record; + if (typeof d.swagger === 'string' && d.swagger.startsWith('2')) return parseOpenApi2(d); + if (typeof d.openapi === 'string' && d.openapi.startsWith('3')) return parseOpenApi3(d); + if (d.__export_format === 4) return parseInsomnia(d); + if (typeof (d.info as Record)?.schema === 'string' && + String((d.info as Record).schema).includes('v2.1')) return parsePostman(d); + if (d.log && typeof (d.log as Record).version === 'string') return parseHar(d); + throw new Error(`Unrecognised collection format in "${filename}"`); +} From 80494d7a5e6948e1ae63f2851bacb60b5961eddb Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:20:28 +0700 Subject: [PATCH 4/9] feat(api-client): add export lib (Postman/Insomnia/OpenAPI 2&3/workspace) --- src/tools/dev/api-client-export.lib.test.ts | 70 ++++++++++ src/tools/dev/api-client-export.lib.ts | 142 ++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/tools/dev/api-client-export.lib.test.ts create mode 100644 src/tools/dev/api-client-export.lib.ts diff --git a/src/tools/dev/api-client-export.lib.test.ts b/src/tools/dev/api-client-export.lib.test.ts new file mode 100644 index 0000000..f02672c --- /dev/null +++ b/src/tools/dev/api-client-export.lib.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { exportPostman, exportInsomnia, exportOpenApi2, exportOpenApi3, exportWorkspace } from './api-client-export.lib'; +import { defaultRequestDef, defaultWorkspace } from './api-client-store.lib'; +import type { Collection, Environment, Workspace } from './api-client.types'; + +function makeCollection(): Collection { + const req = { + ...defaultRequestDef(), + id: 'r1', name: 'Login', method: 'POST' as const, + url: 'https://api.example.com/login', + headers: [{ key: 'Content-Type', value: 'application/json', enabled: true }], + body: { mode: 'json' as const, content: '{"email":"{{email}}"}' }, + }; + return { id: 'c1', name: 'My API', folders: [], requests: [req] }; +} + +describe('exportPostman', () => { + it('produces valid Postman v2.1 JSON', () => { + const json = exportPostman(makeCollection()); + const parsed = JSON.parse(json); + expect(parsed.info.schema).toContain('v2.1'); + expect(parsed.item[0].name).toBe('Login'); + expect(parsed.item[0].request.method).toBe('POST'); + expect(parsed.item[0].request.url.raw).toBe('https://api.example.com/login'); + }); + it('includes environment as variables', () => { + const env: Environment = { id: 'e1', name: 'dev', vars: { email: 'test@example.com' } }; + const json = exportPostman(makeCollection(), [env]); + const parsed = JSON.parse(json); + expect(parsed.variable).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'email' })])); + }); +}); + +describe('exportInsomnia', () => { + it('produces valid Insomnia v4 JSON', () => { + const json = exportInsomnia(makeCollection()); + const parsed = JSON.parse(json); + expect(parsed.__export_format).toBe(4); + const req = parsed.resources.find((r: Record) => r._type === 'request'); + expect(req.method).toBe('POST'); + expect(req.url).toBe('https://api.example.com/login'); + }); +}); + +describe('exportOpenApi2', () => { + it('produces swagger 2.0 document', () => { + const json = exportOpenApi2(makeCollection()); + const parsed = JSON.parse(json); + expect(parsed.swagger).toBe('2.0'); + expect(parsed.paths['/login']).toBeDefined(); + }); +}); + +describe('exportOpenApi3', () => { + it('produces openapi 3.1.0 document', () => { + const json = exportOpenApi3(makeCollection()); + const parsed = JSON.parse(json); + expect(parsed.openapi).toBe('3.1.0'); + expect(parsed.paths['/login']).toBeDefined(); + }); +}); + +describe('exportWorkspace', () => { + it('round-trips a workspace', () => { + const w: Workspace = { ...defaultWorkspace(), envs: [{ id: 'e1', name: 'dev', vars: { token: 'abc' } }] }; + const json = exportWorkspace(w); + const parsed = JSON.parse(json) as Workspace; + expect(parsed.envs[0].vars.token).toBe('abc'); + }); +}); diff --git a/src/tools/dev/api-client-export.lib.ts b/src/tools/dev/api-client-export.lib.ts new file mode 100644 index 0000000..d9b0a4f --- /dev/null +++ b/src/tools/dev/api-client-export.lib.ts @@ -0,0 +1,142 @@ +import type { Collection, Environment, Folder, RequestDef, Workspace } from './api-client.types'; + +function allRequests(c: Collection | Folder): RequestDef[] { + return [...c.requests, ...c.folders.flatMap(f => allRequests(f))]; +} + +function urlPath(url: string): string { + try { return new URL(url).pathname; } catch { return url; } +} + +function urlHost(url: string): string { + try { const u = new URL(url); return u.host; } catch { return 'example.com'; } +} + +// ---- Postman v2.1 ---- + +function reqToPostmanItem(req: RequestDef): Record { + return { + name: req.name, + request: { + method: req.method, + url: { raw: req.url, host: [urlHost(req.url)], path: urlPath(req.url).split('/').filter(Boolean) }, + header: req.headers.map(h => ({ key: h.key, value: h.value, disabled: !h.enabled })), + body: req.body.mode === 'json' ? { mode: 'raw', raw: req.body.content } : + req.body.mode === 'form' ? { mode: 'urlencoded', urlencoded: req.body.fields.map(f => ({ key: f.key, value: f.value, disabled: !f.enabled })) } : + undefined, + }, + response: [], + }; +} + +function folderToPostmanItem(folder: Folder): Record { + return { + name: folder.name, + item: [ + ...folder.folders.map(f => folderToPostmanItem(f)), + ...folder.requests.map(r => reqToPostmanItem(r)), + ], + }; +} + +export function exportPostman(c: Collection, envs: Environment[] = []): string { + const variables = envs.flatMap(e => + Object.entries(e.vars).map(([key, value]) => ({ key, value, type: 'string' })) + ); + const doc = { + info: { name: c.name, schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json' }, + item: [ + ...c.folders.map(f => folderToPostmanItem(f)), + ...c.requests.map(r => reqToPostmanItem(r)), + ], + variable: variables.length > 0 ? variables : undefined, + }; + return JSON.stringify(doc, null, 2); +} + +// ---- Insomnia v4 ---- + +export function exportInsomnia(c: Collection, envs: Environment[] = []): string { + const wrkId = `wrk_${c.id}`; + const resources: Record[] = [ + { _id: wrkId, _type: 'workspace', name: c.name, parentId: null, modified: Date.now(), created: Date.now() }, + ]; + + function addFolder(f: Folder, parentId: string) { + const fId = `fld_${f.id}`; + resources.push({ _id: fId, _type: 'request_group', name: f.name, parentId, modified: Date.now(), created: Date.now() }); + f.folders.forEach(sub => addFolder(sub, fId)); + f.requests.forEach(r => addReq(r, fId)); + } + + function addReq(req: RequestDef, parentId: string) { + resources.push({ + _id: `req_${req.id}`, _type: 'request', + name: req.name, method: req.method, url: req.url, parentId, + headers: req.headers.map(h => ({ name: h.key, value: h.value })), + body: req.body.mode === 'json' ? { mimeType: 'application/json', text: req.body.content } : + req.body.mode === 'form' ? { mimeType: 'application/x-www-form-urlencoded', params: req.body.fields.map(f => ({ name: f.key, value: f.value })) } : + {}, + modified: Date.now(), created: Date.now(), + }); + } + + c.folders.forEach(f => addFolder(f, wrkId)); + c.requests.forEach(r => addReq(r, wrkId)); + + envs.forEach(env => { + resources.push({ _id: `env_${env.id}`, _type: 'environment', name: env.name, parentId: wrkId, data: env.vars, modified: Date.now(), created: Date.now() }); + }); + + return JSON.stringify({ __export_format: 4, __export_date: new Date().toISOString(), __export_source: 'goodwebtools', resources }, null, 2); +} + +// ---- OpenAPI 2 ---- + +export function exportOpenApi2(c: Collection): string { + const reqs = allRequests(c); + const host = reqs.length > 0 ? urlHost(reqs[0].url) : 'example.com'; + const paths: Record> = {}; + for (const req of reqs) { + const path = urlPath(req.url) || '/'; + const method = req.method.toLowerCase(); + if (!paths[path]) paths[path] = {}; + paths[path][method] = { + summary: req.name, + operationId: req.name.replace(/\s+/g, '_').toLowerCase(), + parameters: req.params.filter(p => p.enabled).map(p => ({ in: 'query', name: p.key, type: 'string' })), + responses: { '200': { description: 'OK' } }, + }; + } + return JSON.stringify({ swagger: '2.0', info: { title: c.name, version: '1.0.0' }, host, basePath: '/', paths }, null, 2); +} + +// ---- OpenAPI 3.1 ---- + +export function exportOpenApi3(c: Collection): string { + const reqs = allRequests(c); + const base = reqs.length > 0 ? (() => { try { const u = new URL(reqs[0].url); return `${u.protocol}//${u.host}`; } catch { return 'https://example.com'; } })() : 'https://example.com'; + const paths: Record> = {}; + for (const req of reqs) { + const path = urlPath(req.url) || '/'; + const method = req.method.toLowerCase(); + if (!paths[path]) paths[path] = {}; + const op: Record = { + summary: req.name, + operationId: req.name.replace(/\s+/g, '_').toLowerCase(), + parameters: req.params.filter(p => p.enabled).map(p => ({ in: 'query', name: p.key, schema: { type: 'string' } })), + responses: { '200': { description: 'OK' } }, + }; + if (req.body.mode === 'json') { + op.requestBody = { content: { 'application/json': { schema: { type: 'object' } } } }; + } + paths[path][method] = op; + } + return JSON.stringify({ openapi: '3.1.0', info: { title: c.name, version: '1.0.0' }, servers: [{ url: base }], paths }, null, 2); +} + +// ---- GWT Workspace ---- + +export function exportWorkspace(w: Workspace): string { + return JSON.stringify(w, null, 2); +} From e779b45215814a09b4ca058e37f421dddbc68121 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:21:19 +0700 Subject: [PATCH 5/9] =?UTF-8?q?feat(api-client):=20add=20env=20lib=20?= =?UTF-8?q?=E2=80=94=20substituteVars,=20JSONPath,=20binding=20resolution,?= =?UTF-8?q?=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/dev/api-client-env.lib.test.ts | 86 ++++++++++++++++++++++++ src/tools/dev/api-client-env.lib.ts | 78 +++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/tools/dev/api-client-env.lib.test.ts create mode 100644 src/tools/dev/api-client-env.lib.ts diff --git a/src/tools/dev/api-client-env.lib.test.ts b/src/tools/dev/api-client-env.lib.test.ts new file mode 100644 index 0000000..acd5eae --- /dev/null +++ b/src/tools/dev/api-client-env.lib.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { substituteVars, evaluateJsonPath, resolveBinding, applyCapture, substituteRequest } from './api-client-env.lib'; +import { defaultRequestDef } from './api-client-store.lib'; +import type { VarBinding, RequestDef } from './api-client.types'; + +describe('substituteVars', () => { + it('replaces {{var}} with env value', () => { + expect(substituteVars('Bearer {{token}}', { token: 'abc123' })).toBe('Bearer abc123'); + }); + it('leaves unknown vars unreplaced', () => { + expect(substituteVars('{{unknown}}', {})).toBe('{{unknown}}'); + }); + it('replaces multiple occurrences', () => { + expect(substituteVars('{{a}}-{{a}}', { a: 'x' })).toBe('x-x'); + }); +}); + +describe('evaluateJsonPath', () => { + const body = JSON.stringify({ data: { token: 'mytoken', nested: { val: 42 } }, items: ['a', 'b'] }); + it('extracts nested field', () => { + expect(evaluateJsonPath(body, '$.data.token')).toBe('mytoken'); + }); + it('extracts deeply nested', () => { + expect(evaluateJsonPath(body, '$.data.nested.val')).toBe('42'); + }); + it('extracts array element', () => { + expect(evaluateJsonPath(body, '$.items[0]')).toBe('a'); + }); + it('returns undefined for missing path', () => { + expect(evaluateJsonPath(body, '$.missing.field')).toBeUndefined(); + }); + it('returns undefined on invalid JSON', () => { + expect(evaluateJsonPath('not-json', '$.a')).toBeUndefined(); + }); +}); + +describe('resolveBinding', () => { + const saved: RequestDef = { + ...defaultRequestDef(), id: 'req-login', + responseHistory: [{ status: 200, statusText: 'OK', headers: {}, body: JSON.stringify({ token: 'saved-token' }), durationMs: 50 }], + }; + + it('resolves env binding', () => { + const bindings: VarBinding[] = [{ name: 'token', source: { type: 'env', varName: 'token' } }]; + expect(resolveBinding('token', bindings, [], { token: 'env-token' })).toBe('env-token'); + }); + + it('resolves response binding from saved request', () => { + const bindings: VarBinding[] = [{ name: 'token', source: { type: 'response', requestId: 'req-login', jsonPath: '$.token' } }]; + expect(resolveBinding('token', bindings, [saved], {})).toBe('saved-token'); + }); + + it('falls back to env var when no binding defined', () => { + expect(resolveBinding('token', [], [], { token: 'fallback' })).toBe('fallback'); + }); + + it('returns undefined when nothing found', () => { + expect(resolveBinding('missing', [], [], {})).toBeUndefined(); + }); +}); + +describe('applyCapture', () => { + it('writes captured value into vars copy', () => { + const body = JSON.stringify({ access_token: 'newtoken' }); + const result = applyCapture({ jsonPath: '$.access_token', intoVar: 'token' }, body, { existing: 'val' }); + expect(result.token).toBe('newtoken'); + expect(result.existing).toBe('val'); + }); + it('leaves vars unchanged when jsonPath misses', () => { + const result = applyCapture({ jsonPath: '$.missing', intoVar: 'token' }, '{}', {}); + expect(result.token).toBeUndefined(); + }); +}); + +describe('substituteRequest', () => { + it('substitutes {{var}} in URL and header value', () => { + const req: RequestDef = { + ...defaultRequestDef(), + url: 'https://{{host}}/users', + headers: [{ key: 'Authorization', value: 'Bearer {{token}}', enabled: true }], + }; + const result = substituteRequest(req, [], { host: 'api.example.com', token: 'abc' }); + expect(result.url).toBe('https://api.example.com/users'); + expect(result.headers[0].value).toBe('Bearer abc'); + }); +}); diff --git a/src/tools/dev/api-client-env.lib.ts b/src/tools/dev/api-client-env.lib.ts new file mode 100644 index 0000000..2eea52d --- /dev/null +++ b/src/tools/dev/api-client-env.lib.ts @@ -0,0 +1,78 @@ +import type { RequestDef, VarBinding, CaptureRule } from './api-client.types'; + +export function substituteVars(text: string, vars: Record): string { + return text.replace(/\{\{(\w+)\}\}/g, (_, name) => vars[name] ?? `{{${name}}}`); +} + +export function evaluateJsonPath(body: string, path: string): string | undefined { + try { + let obj: unknown = JSON.parse(body); + const parts = path.replace(/^\$\.?/, '').split('.'); + for (const part of parts) { + if (obj === null || obj === undefined) return undefined; + const arrMatch = part.match(/^(\w+)\[(\d+)\]$/); + if (arrMatch) { + obj = (obj as Record)[arrMatch[1]]; + obj = (obj as unknown[])[Number(arrMatch[2])]; + } else { + obj = (obj as Record)[part]; + } + } + return obj !== undefined && obj !== null ? String(obj) : undefined; + } catch { + return undefined; + } +} + +export function resolveBinding( + name: string, + bindings: VarBinding[], + allRequests: RequestDef[], + envVars: Record, +): string | undefined { + const binding = bindings.find(b => b.name === name); + if (binding) { + if (binding.source.type === 'env') return envVars[binding.source.varName]; + if (binding.source.type === 'response') { + const src = binding.source as { type: 'response'; requestId: string; jsonPath: string }; + const req = allRequests.find(r => r.id === src.requestId); + const snapshot = req?.responseHistory[0]; + if (snapshot) return evaluateJsonPath(snapshot.body, src.jsonPath); + } + } + return envVars[name]; +} + +export function applyCapture( + rule: CaptureRule, + responseBody: string, + vars: Record, +): Record { + const value = evaluateJsonPath(responseBody, rule.jsonPath); + if (value === undefined) return vars; + return { ...vars, [rule.intoVar]: value }; +} + +export function substituteRequest( + req: RequestDef, + allRequests: RequestDef[], + envVars: Record, +): RequestDef { + const resolve = (text: string) => + text.replace(/\{\{(\w+)\}\}/g, (_, name) => resolveBinding(name, req.bindings, allRequests, envVars) ?? `{{${name}}}`); + + return { + ...req, + url: resolve(req.url), + params: req.params.map(p => ({ ...p, value: resolve(p.value) })), + headers: req.headers.map(h => ({ ...h, value: resolve(h.value) })), + body: req.body.mode === 'json' ? { ...req.body, content: resolve(req.body.content) } : + req.body.mode === 'raw' ? { ...req.body, content: resolve(req.body.content) } : + req.body.mode === 'form' ? { ...req.body, fields: req.body.fields.map(f => ({ ...f, value: resolve(f.value) })) } : + req.body, + auth: req.auth.type === 'bearer' ? { ...req.auth, token: resolve(req.auth.token) } : + req.auth.type === 'basic' ? { ...req.auth, username: resolve(req.auth.username), password: resolve(req.auth.password) } : + req.auth.type === 'api-key' ? { ...req.auth, value: resolve(req.auth.value) } : + req.auth, + }; +} From 7e5949bf16b41647a3097cd6fa20490061d6891f Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:21:59 +0700 Subject: [PATCH 6/9] =?UTF-8?q?feat(api-client):=20add=20request=20lib=20?= =?UTF-8?q?=E2=80=94=20buildFetchInit=20and=20executeRequest=20(fetch=20+?= =?UTF-8?q?=20Tauri=20dispatch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/dev/api-client-request.lib.test.ts | 55 ++++++++++++++ src/tools/dev/api-client-request.lib.ts | 80 ++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/tools/dev/api-client-request.lib.test.ts create mode 100644 src/tools/dev/api-client-request.lib.ts diff --git a/src/tools/dev/api-client-request.lib.test.ts b/src/tools/dev/api-client-request.lib.test.ts new file mode 100644 index 0000000..7ea3e13 --- /dev/null +++ b/src/tools/dev/api-client-request.lib.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { buildFetchInit } from './api-client-request.lib'; +import { defaultRequestDef } from './api-client-store.lib'; +import type { RequestDef } from './api-client.types'; + +describe('buildFetchInit', () => { + it('builds GET with no body', () => { + const req: RequestDef = { ...defaultRequestDef(), method: 'GET', url: 'https://api.example.com/users' }; + const { url, init } = buildFetchInit(req); + expect(url).toBe('https://api.example.com/users'); + expect(init.method).toBe('GET'); + expect(init.body).toBeUndefined(); + }); + + it('appends enabled query params to URL', () => { + const req: RequestDef = { + ...defaultRequestDef(), method: 'GET', url: 'https://api.example.com/users', + params: [{ key: 'page', value: '1', enabled: true }, { key: 'skip', value: '0', enabled: false }], + }; + const { url } = buildFetchInit(req); + expect(url).toContain('page=1'); + expect(url).not.toContain('skip'); + }); + + it('sets Content-Type for JSON body', () => { + const req: RequestDef = { + ...defaultRequestDef(), method: 'POST', url: 'https://api.example.com/login', + body: { mode: 'json', content: '{"email":"test"}' }, + }; + const { init } = buildFetchInit(req); + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(init.body).toBe('{"email":"test"}'); + }); + + it('sets Authorization header for bearer auth', () => { + const req: RequestDef = { + ...defaultRequestDef(), method: 'GET', url: 'https://api.example.com/', + auth: { type: 'bearer', token: 'mytoken' }, + }; + const { init } = buildFetchInit(req); + const headers = init.headers as Record; + expect(headers['Authorization']).toBe('Bearer mytoken'); + }); + + it('sets Authorization header for basic auth', () => { + const req: RequestDef = { + ...defaultRequestDef(), method: 'GET', url: 'https://api.example.com/', + auth: { type: 'basic', username: 'user', password: 'pass' }, + }; + const { init } = buildFetchInit(req); + const headers = init.headers as Record; + expect(headers['Authorization']).toMatch(/^Basic /); + }); +}); diff --git a/src/tools/dev/api-client-request.lib.ts b/src/tools/dev/api-client-request.lib.ts new file mode 100644 index 0000000..5e4c6ae --- /dev/null +++ b/src/tools/dev/api-client-request.lib.ts @@ -0,0 +1,80 @@ +import { isTauri } from '@/services/platform'; +import type { RequestDef, ResponseSnapshot } from './api-client.types'; + +export function buildFetchInit(req: RequestDef): { url: string; init: RequestInit } { + const headers: Record = {}; + + for (const h of req.headers) { + if (h.enabled) headers[h.key] = h.value; + } + + if (req.auth.type === 'bearer') { + headers['Authorization'] = `Bearer ${req.auth.token}`; + } else if (req.auth.type === 'basic') { + const encoded = btoa(`${req.auth.username}:${req.auth.password}`); + headers['Authorization'] = `Basic ${encoded}`; + } else if (req.auth.type === 'api-key') { + headers[req.auth.header] = req.auth.value; + } + + let url = req.url; + const enabledParams = req.params.filter(p => p.enabled); + if (enabledParams.length > 0) { + const sep = url.includes('?') ? '&' : '?'; + url += sep + enabledParams.map(p => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join('&'); + } + + let body: BodyInit | undefined; + if (req.body.mode === 'json') { + headers['Content-Type'] = 'application/json'; + body = req.body.content; + } else if (req.body.mode === 'form') { + const form = new URLSearchParams(); + req.body.fields.filter(f => f.enabled).forEach(f => form.append(f.key, f.value)); + body = form.toString(); + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } else if (req.body.mode === 'raw') { + body = req.body.content; + if (req.body.contentType) headers['Content-Type'] = req.body.contentType; + } + + return { url, init: { method: req.method, headers, body } }; +} + +export async function executeRequest(req: RequestDef): Promise { + const { url, init } = buildFetchInit(req); + const start = performance.now(); + + if (isTauri()) { + const { invoke } = await import('@tauri-apps/api/core'); + const result = await invoke<{ + status: number; status_text: string; + headers: Record; body: string; duration_ms: number; + }>('http_request', { + method: req.method, + url, + headers: init.headers as Record, + body: init.body ? String(init.body) : null, + }); + return { + status: result.status, + statusText: result.status_text, + headers: result.headers, + body: result.body, + durationMs: result.duration_ms, + }; + } + + const res = await fetch(url, init); + const durationMs = Math.round(performance.now() - start); + const responseHeaders: Record = {}; + res.headers.forEach((value, key) => { responseHeaders[key] = value; }); + const body = await res.text(); + return { + status: res.status, + statusText: res.statusText, + headers: responseHeaders, + body, + durationMs, + }; +} From 97c55c9e22e7d86ef4b754f31fcad5dc7f51fb7e Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:46:41 +0700 Subject: [PATCH 7/9] feat(api-client): add Tauri http_request command using reqwest --- src-tauri/Cargo.lock | 165 +++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 61 ++++++++++++++ src-tauri/src/main.rs | 1 + 4 files changed, 225 insertions(+), 3 deletions(-) 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"); From 57d82713a8e23bfe1fe7ec97ca287361a1fcf413 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:05:35 +0700 Subject: [PATCH 8/9] =?UTF-8?q?feat(api-client):=20add=20ApiClient=20islan?= =?UTF-8?q?d=20=E2=80=94=20two-column=20UI=20with=20auto-save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/islands/dev/ApiClient.tsx | 691 ++++++++++++++++++++++++++++++++++ 1 file changed, 691 insertions(+) create mode 100644 src/islands/dev/ApiClient.tsx diff --git a/src/islands/dev/ApiClient.tsx b/src/islands/dev/ApiClient.tsx new file mode 100644 index 0000000..af54459 --- /dev/null +++ b/src/islands/dev/ApiClient.tsx @@ -0,0 +1,691 @@ +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, ResponseSnapshot, 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-next-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); + s.has(id) ? s.delete(id) : 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

+