diff --git a/agent/package.json b/agent/package.json index f331b08..5c914c8 100644 --- a/agent/package.json +++ b/agent/package.json @@ -1,6 +1,6 @@ { "name": "@testsmith/api-spector-agent", - "version": "0.5.0", + "version": "0.5.1", "description": "API Spector private runner: monitor internal/local APIs the cloud cannot reach, with no inbound connections.", "license": "MIT", "homepage": "https://api-spector.dev", diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 03bec02..10130ca 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -10,6 +10,7 @@ - [Data-Driven Runs](gui/data-driven.md) - [Mock Servers](gui/mock-servers.md) - [Export to Code](gui/code-generation.md) + - [Table View for Arrays](gui/response-table.md) - [TLS & Certificates](gui/tls-certificates.md) - [Import OpenAPI](gui/import-openapi.md) - [HTTP Files (.http / .rest)](gui/http-files.md) diff --git a/docs/gui/response-table.md b/docs/gui/response-table.md new file mode 100644 index 0000000..a8bb121 --- /dev/null +++ b/docs/gui/response-table.md @@ -0,0 +1,56 @@ +# Table view for array responses + +When a response body contains an array, API Spector can show it as a **sortable +table** instead of raw text or a tree. It works for both JSON and XML. + +## Opening it + +On the **Body** tab, a **Table** toggle appears next to Tree / Raw **whenever the +response actually contains an array**. Click it to switch to the table. + +## The path (starting point) + +API Spector auto-detects the most likely array - the root if the body is an +array, otherwise the array under a data-ish key (`data`, `items`, `results`, +`rows`, ...). The detected location is shown in the **Path** box at the top. + +To tabulate a different array, edit the path, for example: + +``` +data.items +data.orders +report.rows +``` + +Paths support dots and indexes (`data.pages[0].items`). Clear it (or click +**root**) to go back to the top. + +## Sorting + +Click a column header to sort by that column: first click ascending, second +descending, third back to the response's original order. Numeric columns sort +numerically; everything else sorts as text, with empty/null values last. Sorting +only changes the view - the row numbers on the left are the original positions. + +## Nested arrays and objects + +Columns are the union of the rows' keys. A cell that is itself an array shows its +length (e.g. `[3]`) and an object shows `{...}`; both are **clickable** - click to +drill into that nested value, and the Path updates so you can see and edit where +you are. This is how you tabulate a nested array: click into it, or type its path +directly. + +If the path points at something that **isn't** an array - the deepest item is an +object, or a single value - it's shown as-is: an object as a key/value list (whose +nested arrays and objects are still clickable to drill further), a primitive as +its value. So drilling never dead-ends. + +To go back up, use the **breadcrumb** at the top: **↑ up** returns one level, +**root** returns to the top, and every crumb (`root › data › items › [2]`) is +clickable to jump straight to that level. + +## Notes + +- Very large arrays render the first 1000 rows (with a note); refine the path or + sort to find what you need. +- XML is converted to the same shape - repeated sibling elements become the rows. diff --git a/package-lock.json b/package-lock.json index d0ab51d..237b25b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@testsmith/api-spector", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@testsmith/api-spector", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.3", diff --git a/package.json b/package.json index 7eb9d33..e1dbafd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@testsmith/api-spector", "productName": "API Spector", - "version": "0.5.0", + "version": "0.5.1", "description": "Local-first API testing tool to inspect, test and mock APIs", "repository": { "type": "git", diff --git a/src/renderer/src/components/ResponseViewer/ResponseTable.tsx b/src/renderer/src/components/ResponseViewer/ResponseTable.tsx new file mode 100644 index 0000000..614e2e6 --- /dev/null +++ b/src/renderer/src/components/ResponseViewer/ResponseTable.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +import React, { useEffect, useMemo, useState } from 'react'; +import { + findPrimaryArray, getByPath, joinPath, tableColumns, sortedIndices, classify, cellPreview, +} from '../../../../shared/response-table'; + +const MAX_ROWS = 1000; + +// Convert an XML document into a plain JS structure so the same table logic +// works: repeated sibling elements become arrays, attributes are prefixed "@". +function xmlToJson(node: Element): unknown { + const out: Record = {}; + for (const attr of Array.from(node.attributes)) out[`@${attr.name}`] = attr.value; + + const childEls = Array.from(node.children); + if (childEls.length === 0) { + const text = node.textContent?.trim() ?? ''; + return node.attributes.length ? { ...out, '#text': text } : text; + } + for (const child of childEls) { + const val = xmlToJson(child); + if (child.tagName in out) { + const existing = out[child.tagName]; + if (Array.isArray(existing)) existing.push(val); + else out[child.tagName] = [existing, val]; + } else { + out[child.tagName] = val; + } + } + return out; +} + +function parseBody(body: string, contentType: string): { ok: true; value: unknown } | { ok: false; error: string } { + const isXml = !contentType.includes('json') && (contentType.includes('xml') || body.trim().startsWith('<')); + try { + if (isXml) { + const doc = new DOMParser().parseFromString(body, 'application/xml'); + if (doc.querySelector('parsererror')) throw new Error('Malformed XML'); + return { ok: true, value: doc.documentElement ? xmlToJson(doc.documentElement) : {} }; + } + return { ok: true, value: JSON.parse(body) }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** Whether a response body has any array worth tabulating (drives the toggle). */ +export function bodyHasArray(body: string, contentType: string): boolean { + const parsed = parseBody(body, contentType); + return parsed.ok && findPrimaryArray(parsed.value) !== null; +} + +interface Props { body: string; contentType: string } + +export function ResponseTable({ body, contentType }: Props) { + const parsed = useMemo(() => parseBody(body, contentType), [body, contentType]); + const detected = useMemo(() => (parsed.ok ? findPrimaryArray(parsed.value) : null), [parsed]); + + const [path, setPath] = useState(''); + const [sort, setSort] = useState<{ col: string; dir: 'asc' | 'desc' } | null>(null); + + // Reset to the auto-detected array whenever the response changes. + useEffect(() => { setPath(detected?.path ?? ''); setSort(null); }, [detected]); + + if (!parsed.ok) return
Could not parse body: {parsed.error}
; + + const target = getByPath(parsed.value, path); + const rows = Array.isArray(target) ? target : null; + + const columns = rows ? tableColumns(rows) : []; + const isPrimitiveRows = rows !== null && columns.length === 0; + const sortCol = sort?.col ?? null; + + // Display order (plain computation, must stay below the early return above). + const order = !rows + ? [] + : (sort ? sortedIndices(rows, sort.col, sort.dir) : rows.map((_, i) => i)).slice(0, MAX_ROWS); + + function toggleSort(col: string) { + setSort(s => s?.col === col ? (s.dir === 'asc' ? { col, dir: 'desc' } : null) : { col, dir: 'asc' }); + } + + // Drill into a nested array/object cell (uses the ORIGINAL row index so the + // path stays correct even when the view is sorted). + // Any navigation (drill in, breadcrumb, edit) resets the sort, since the + // target - and thus its columns - changes. + function navigate(p: string) { setPath(p); setSort(null); } + + function drill(originalIndex: number, col: string | null, value: unknown) { + if (classify(value) === 'array' || classify(value) === 'object') { + navigate(joinPath(path, originalIndex, col ?? undefined)); + } + } + + // Drill into a field of an object (not an array element). + function drillKey(key: string, value: unknown) { + if (classify(value) === 'array' || classify(value) === 'object') { + navigate(path ? `${path}.${key}` : key); + } + } + + // Path split into crumbs, each with the path that jumps to that level. + const crumbs = (path.match(/[^.[\]]+|\[\d+\]/g) ?? []).reduce<{ label: string; path: string }[]>((acc, tok) => { + const prev = acc.length ? acc[acc.length - 1].path : ''; + const next = tok.startsWith('[') ? prev + tok : (prev ? `${prev}.${tok}` : tok); + acc.push({ label: tok, path: next }); + return acc; + }, []); + const parentPath = crumbs.length > 1 ? crumbs[crumbs.length - 2].path : ''; + + const th = 'px-3 py-1.5 text-left font-medium text-surface-300 border-b border-surface-800 cursor-pointer select-none whitespace-nowrap hover:text-white'; + const td = 'px-3 py-1 border-b border-surface-800/50 align-top'; + const arrow = (col: string) => sortCol === col ? (sort!.dir === 'asc' ? ' ▲' : ' ▼') : ''; + + return ( +
+ {/* Breadcrumb: click any level to jump back; up-one-level button */} +
+ {path && ( +
+ + + {crumbs.map((c, i) => ( + + + + + ))} +
+ )} +
+ Path + navigate(e.target.value)} + placeholder="(root) e.g. data.items" + className="flex-1 bg-surface-800 border border-surface-700 rounded px-2 py-1 font-mono text-[11px] focus:outline-none focus:border-blue-500" + /> + + {rows ? `${rows.length} rows${columns.length ? ` · ${columns.length} cols` : ''}` + : classify(target) === 'object' ? `${Object.keys(target as object).length} fields` + : ''} + +
+
+ +
+ {rows ? ( + + + + + {isPrimitiveRows ? ( + + ) : ( + columns.map(col => ) + )} + + + + {order.map(i => { + const row = rows[i]; + return ( + + + {isPrimitiveRows ? ( + + ) : ( + columns.map(col => { + const v = row && typeof row === 'object' ? (row as Record)[col] : undefined; + return ; + }) + )} + + ); + })} + +
# toggleSort('$value')}>value{arrow('$value')} toggleSort(col)}>{col}{arrow(col)}
{i} drill(i, null, row)} /> drill(i, col, v)} />
+ ) : classify(target) === 'object' ? ( + // The path points at an object, not an array: show it as fields. + + + {Object.entries(target as Record).map(([k, v]) => ( + + + + + ))} + +
{k} drillKey(k, v)} />
+ ) : target !== undefined ? ( + // A primitive (or null): just show the value. +
{String(target)}
+ ) : ( +
+ Nothing at {path || '(root)'}. + {detected ? <> Detected array: : ''} +
+ )} + {rows && rows.length > MAX_ROWS && ( +
Showing first {MAX_ROWS} of {rows.length} rows.
+ )} +
+
+ ); +} + +function Cell({ value, onDrill }: { value: unknown; onDrill: () => void }) { + const kind = classify(value); + if (kind === 'array' || kind === 'object') { + return ( + + ); + } + if (kind === 'null') return null; + // Keep cells to a single line and truncate long values (ids, tokens) with an + // ellipsis so a long id can't blow up the row height; full value on hover. + const text = String(value); + return ( + 40 ? text : undefined}> + {text} + + ); +} diff --git a/src/renderer/src/components/ResponseViewer/ResponseViewer.tsx b/src/renderer/src/components/ResponseViewer/ResponseViewer.tsx index 8f2f5ce..5c16b45 100644 --- a/src/renderer/src/components/ResponseViewer/ResponseViewer.tsx +++ b/src/renderer/src/components/ResponseViewer/ResponseViewer.tsx @@ -10,6 +10,7 @@ import { oneDark } from '@codemirror/theme-one-dark'; import { getStatusColor, getMethodColor } from '../../../../shared/colors'; import type { HistoryEntry } from '../../../../shared/types'; import { InteractiveBody } from './InteractiveBody'; +import { ResponseTable, bodyHasArray } from './ResponseTable'; import { StreamView } from './StreamView'; import { HookResultsPanel } from './HookResultsPanel'; import { SaveAsMockModal } from './SaveAsMockModal'; @@ -181,7 +182,7 @@ export function ResponseViewer() { const [diffMode, setDiffMode] = useState(false); const [showMockModal, setShowMockModal] = useState(false); - const [bodyView, setBodyView] = useState<'tree' | 'raw'>('raw'); + const [bodyView, setBodyView] = useState<'tree' | 'raw' | 'table'>('raw'); const assertToast = useToast(2500); const contractToast = useToast(2500); @@ -256,6 +257,9 @@ export function ResponseViewer() { const isXml = !isJson && (contentType.includes('xml') || contentType.includes('html')); const supportsTree = isJson || isXml; const displayBody = isJson ? prettyJson(response.body) : isXml ? prettyXml(response.body) : response.body; + // Show the Table view only when the body actually has an array to tabulate. + // (A plain computation, not a hook: this sits after early returns above.) + const showTable = supportsTree && !response.streamed && bodyHasArray(response.body, contentType); // Body parse error (for a red ! on the Body tab, regardless of tree/raw view). const bodyParseError = response.body.trim().length > 0 && ( @@ -367,6 +371,15 @@ export function ResponseViewer() { > Raw + {showTable && ( + + )} )} @@ -437,6 +450,8 @@ export function ResponseViewer() { streamClose={response.streamClose} firstEventMs={response.firstEventMs} /> + ) : tab === 'body' && showTable && bodyView === 'table' ? ( + ) : tab === 'body' && supportsTree && bodyView === 'tree' ? ( )[tok]; + } + return cur; +} + +/** Append a row index (and optional key) to a path: joinPath("data.items", 2, + * "tags") -> "data.items[2].tags". Used for drilling into a nested cell. */ +export function joinPath(base: string, index: number, key?: string): string { + return `${base}[${index}]${key ? `.${key}` : ''}`; +} + +const PREFERRED = /^(data|items|results|records|rows|list|content|entries|values|payload|elements)$/i; + +/** Auto-pick the array most likely to be "the data": the root if it's an array, + * else the shallowest array under a data-ish key, preferring arrays of objects + * and larger arrays. Returns its path (for the starting-point input). */ +export function findPrimaryArray(data: unknown): { path: string; array: unknown[] } | null { + if (Array.isArray(data)) return { path: '', array: data }; + + const found: { path: string; array: unknown[]; pref: number; objs: number; depth: number }[] = []; + const queue: { node: unknown; path: string; depth: number }[] = [{ node: data, path: '', depth: 0 }]; + while (queue.length) { + const { node, path, depth } = queue.shift()!; + if (depth > 6 || node === null || typeof node !== 'object') continue; + if (Array.isArray(node)) { + const key = (path.split('.').pop() ?? '').replace(/\[\d+\]/g, ''); + const objs = node.filter(x => x && typeof x === 'object' && !Array.isArray(x)).length; + found.push({ path, array: node, pref: PREFERRED.test(key) ? 1 : 0, objs, depth }); + // Do not descend into array elements: the primary array is near the top. + } else { + for (const [k, v] of Object.entries(node)) { + queue.push({ node: v, path: path ? `${path}.${k}` : k, depth: depth + 1 }); + } + } + } + if (!found.length) return null; + + found.sort((a, b) => + (b.pref - a.pref) || + (Math.sign(b.objs) - Math.sign(a.objs)) || + (b.array.length - a.array.length) || + (a.depth - b.depth), + ); + return { path: found[0].path, array: found[0].array }; +} + +/** Column keys for a set of rows: the union of object keys in first-seen order. + * Empty means the rows are primitives/arrays (render a single value column). */ +export function tableColumns(rows: unknown[], cap = 60): string[] { + const cols: string[] = []; + const seen = new Set(); + for (const row of rows) { + if (row && typeof row === 'object' && !Array.isArray(row)) { + for (const k of Object.keys(row)) { + if (!seen.has(k)) { seen.add(k); cols.push(k); if (cols.length >= cap) return cols; } + } + } + } + return cols; +} + +export function classify(v: unknown): CellKind { + if (v === null || v === undefined) return 'null'; + if (Array.isArray(v)) return 'array'; + if (typeof v === 'object') return 'object'; + return 'primitive'; +} + +/** Compact text for a cell: primitives as-is, arrays as "[n]", objects as + * truncated JSON. */ +export function cellPreview(v: unknown): string { + switch (classify(v)) { + case 'null': return ''; + case 'array': return `[${(v as unknown[]).length}]`; + case 'object': { const s = JSON.stringify(v); return s.length > 60 ? s.slice(0, 57) + '…' : s; } + default: return String(v); + } +} + +/** Compare two cell values: numeric when both are numbers, else string + * compare; nulls sort last. */ +export function compareValues(x: unknown, y: unknown): number { + if (x == null && y == null) return 0; + if (x == null) return 1; + if (y == null) return -1; + const nx = Number(x), ny = Number(y); + if (!Number.isNaN(nx) && !Number.isNaN(ny) && String(x).trim() !== '' && String(y).trim() !== '') return nx - ny; + return String(x).localeCompare(String(y)); +} + +/** The value a column reads from a row ("$value" means the row itself). */ +export function cellAt(row: unknown, col: string): unknown { + return col === '$value' ? row : (row && typeof row === 'object' ? (row as Record)[col] : undefined); +} + +/** Row indices in sorted order, so a sorted view can still map a display row + * back to its original index (for drill-down paths). */ +export function sortedIndices(rows: unknown[], col: string, dir: 'asc' | 'desc'): number[] { + const idx = rows.map((_, i) => i); + idx.sort((a, b) => compareValues(cellAt(rows[a], col), cellAt(rows[b], col))); + return dir === 'desc' ? idx.reverse() : idx; +} + +/** Sort rows by a column (or the whole row for a primitive column, key + * "$value"). Returns a new array. */ +export function sortRows(rows: T[], col: string, dir: 'asc' | 'desc'): T[] { + return sortedIndices(rows as unknown[], col, dir).map(i => rows[i]); +} diff --git a/src/tests/response-table.test.ts b/src/tests/response-table.test.ts new file mode 100644 index 0000000..8f3556b --- /dev/null +++ b/src/tests/response-table.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +import { describe, it, expect } from 'vitest'; +import { getByPath, joinPath, findPrimaryArray, tableColumns, sortRows, classify, cellPreview } from '../shared/response-table'; + +describe('getByPath / joinPath', () => { + const data = { data: { items: [{ id: 1, tags: ['a', 'b'] }, { id: 2 }] } }; + it('reads dotted and indexed paths', () => { + expect(getByPath(data, 'data.items[0].id')).toBe(1); + expect(getByPath(data, 'data.items[0].tags[1]')).toBe('b'); + expect(getByPath(data, '')).toBe(data); + expect(getByPath(data, 'data.missing.x')).toBeUndefined(); + }); + it('builds drill paths', () => { + expect(joinPath('data.items', 2, 'tags')).toBe('data.items[2].tags'); + expect(joinPath('', 0)).toBe('[0]'); + }); +}); + +describe('findPrimaryArray', () => { + it('returns the root when it is an array', () => { + expect(findPrimaryArray([1, 2, 3])).toEqual({ path: '', array: [1, 2, 3] }); + }); + it('prefers a data-ish key over other arrays', () => { + const r = findPrimaryArray({ meta: { tags: [1, 2] }, data: [{ id: 1 }, { id: 2 }] }); + expect(r?.path).toBe('data'); + expect(r?.array).toHaveLength(2); + }); + it('prefers arrays of objects, then size', () => { + const r = findPrimaryArray({ a: [1, 2], b: [{ x: 1 }] }); + expect(r?.path).toBe('b'); + }); + it('returns null when there is no array', () => { + expect(findPrimaryArray({ a: 1, b: { c: 2 } })).toBeNull(); + }); +}); + +describe('tableColumns', () => { + it('unions object keys in first-seen order', () => { + expect(tableColumns([{ id: 1, name: 'a' }, { id: 2, price: 9 }])).toEqual(['id', 'name', 'price']); + }); + it('is empty for primitive rows', () => { + expect(tableColumns([1, 2, 3])).toEqual([]); + }); +}); + +describe('sortRows', () => { + const rows = [{ n: '10' }, { n: '2' }, { n: '30' }]; + it('sorts numerically when cells are numeric', () => { + expect(sortRows(rows, 'n', 'asc').map(r => r.n)).toEqual(['2', '10', '30']); + expect(sortRows(rows, 'n', 'desc').map(r => r.n)).toEqual(['30', '10', '2']); + }); + it('sorts strings and pushes nulls last', () => { + const r = sortRows([{ s: 'b' }, { s: null }, { s: 'a' }], 's', 'asc'); + expect(r.map(x => x.s)).toEqual(['a', 'b', null]); + }); + it('sorts primitive rows via the $value column', () => { + expect(sortRows([3, 1, 2], '$value', 'asc')).toEqual([1, 2, 3]); + }); +}); + +describe('classify / cellPreview', () => { + it('classifies values', () => { + expect(classify(null)).toBe('null'); + expect(classify([1])).toBe('array'); + expect(classify({})).toBe('object'); + expect(classify('x')).toBe('primitive'); + }); + it('previews compactly', () => { + expect(cellPreview([1, 2, 3])).toBe('[3]'); + expect(cellPreview(null)).toBe(''); + expect(cellPreview('hi')).toBe('hi'); + expect(cellPreview({ a: 1 })).toBe('{"a":1}'); + }); +});