diff --git a/packages/chronicle/src/components/api/playground-dialog.tsx b/packages/chronicle/src/components/api/playground-dialog.tsx index ce54c56..29b1de9 100644 --- a/packages/chronicle/src/components/api/playground-dialog.tsx +++ b/packages/chronicle/src/components/api/playground-dialog.tsx @@ -2,10 +2,11 @@ import { useState, useCallback, useMemo, useEffect } from 'react' import type { OpenAPIV3 } from 'openapi-types' -import { Dialog, Button, Badge, IconButton, Input, CopyButton, Select, Menu } from '@raystack/apsara' +import { Dialog, Button, Badge, IconButton, Input, CopyButton, Select, Menu, Switch } from '@raystack/apsara' import { ChevronDownIcon, ChevronUpIcon, CodeIcon, HistoryIcon, PlayIcon, PlusIcon, XIcon } from '@/components/ui/icons'; import { MethodBadge } from '@/components/api/method-badge' -import { flattenSchema, generateExampleJson, toKind, type SchemaField } from '@/lib/schema' +import { flattenSchema, generateExampleJson, toKind, type SchemaField, type SchemaFieldKind } from '@/lib/schema' +import { arrayItemKind, coerceValue, initBodyValues, pruneBody } from '@/lib/playground-body' import { generateCurl } from '@/lib/snippet-generators' import { isStaticMode } from '@/lib/static-mode' import { JsonEditor } from '@/components/api/json-editor' @@ -143,16 +144,7 @@ export function PlaygroundDialog({ const [pathValues, setPathValues] = useState>({}) const [queryValues, setQueryValues] = useState>({}) const [jsonMode, setJsonMode] = useState(false) - const [bodyValues, setBodyValues] = useState>(() => { - if (!body) return {} - const init: Record = {} - for (const f of body.fields) { - if (f.kind === 'array') init[f.name] = [] - else if (f.kind === 'object' || f.children) init[f.name] = {} - else init[f.name] = '' - } - return init - }) + const [bodyValues, setBodyValues] = useState>(() => body ? initBodyValues(body.fields) : {}) const [bodyJsonStr, setBodyJsonStr] = useState(() => body ? body.jsonExample : '{}') useEffect(() => { @@ -200,16 +192,7 @@ export function PlaygroundDialog({ setHeaderValues({}) setPathValues({}) setQueryValues({}) - setBodyValues(() => { - if (!body) return {} - const init: Record = {} - for (const f of body.fields) { - if (f.kind === 'array') init[f.name] = [] - else if (f.kind === 'object' || f.children) init[f.name] = {} - else init[f.name] = '' - } - return init - }) + setBodyValues(body ? initBodyValues(body.fields) : {}) setResponseData(null) } @@ -244,7 +227,7 @@ export function PlaygroundDialog({ return } } else { - reqBody = bodyValues + reqBody = pruneBody(bodyValues) } } @@ -260,7 +243,7 @@ export function PlaygroundDialog({ } finally { setLoading(false) } - }, [specName, method, path, serverUrl, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body]) + }, [specName, method, path, serverUrl, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, bodyJsonStr, jsonMode, body]) const responseJson = responseData ? (typeof responseData.body === 'string' ? responseData.body : JSON.stringify(responseData.body, null, 2)) @@ -275,7 +258,7 @@ export function PlaygroundDialog({ const curlSnippet = useMemo(() => { const headers: Record = { ...getAuthHeaders(), ...headerValues } if (body) headers['Content-Type'] = body.contentType ?? 'application/json' - const bodyStr = body ? (jsonMode ? bodyJsonStr : JSON.stringify(bodyValues)) : undefined + const bodyStr = body ? (jsonMode ? bodyJsonStr : JSON.stringify(pruneBody(bodyValues))) : undefined return generateCurl({ method, url: serverUrl + path, headers, body: bodyStr }) }, [method, path, serverUrl, getAuthHeaders, headerValues, bodyValues, bodyJsonStr, jsonMode, body]) @@ -428,7 +411,7 @@ export function PlaygroundDialog({
{ if (!jsonMode) { - setBodyJsonStr(JSON.stringify(bodyValues, null, 2)) + setBodyJsonStr(JSON.stringify(pruneBody(bodyValues), null, 2)) } else { try { setBodyValues(JSON.parse(bodyJsonStr)) } catch { /* ignore */ } } @@ -561,23 +544,24 @@ function BodyFieldRow({ field, value, onChange }: { const hasChildren = field.children && field.children.length > 0 if (field.kind === 'array' && !hasChildren) { - const items = Array.isArray(value) ? value as string[] : [] + const items = Array.isArray(value) ? value : [] + const itemKind = arrayItemKind(field) return (
{field.name} {field.required && required} - onChange([...items, ''])} aria-label="Add item"> + onChange([...items, itemKind === 'boolean' ? false : ''])} aria-label="Add item">
{items.map((item, i) => (
- { + value={item} + onChange={(v) => { const updated = [...items] updated[i] = v onChange(updated) @@ -618,17 +602,47 @@ function BodyFieldRow({ field, value, onChange }: {
{field.name} {field.required && required}
- onChange(v)} + value={value} + onChange={onChange} />
) } +function ScalarInput({ kind, placeholder, value, onChange }: { + kind: SchemaFieldKind + placeholder: string + value: unknown + onChange: (val: unknown) => void +}) { + const [text, setText] = useState(() => String(value ?? '')) + + useEffect(() => { + if (coerceValue(kind, text) !== value) setText(String(value ?? '')) + }, [kind, value, text]) + + if (kind === 'boolean') { + return onChange(checked)} /> + } + + return ( + { + setText(v) + onChange(coerceValue(kind, v)) + }} + /> + ) +} + function paramsToFields(params: OpenAPIV3.ParameterObject[]): SchemaField[] { return params.map((p) => { const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject diff --git a/packages/chronicle/src/lib/playground-body.test.ts b/packages/chronicle/src/lib/playground-body.test.ts new file mode 100644 index 0000000..61e1167 --- /dev/null +++ b/packages/chronicle/src/lib/playground-body.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'bun:test'; +import { arrayItemKind, coerceValue, initBodyValues, pruneBody } from './playground-body'; +import { flattenSchema, type SchemaField } from './schema'; + +function field(partial: Partial & Pick): SchemaField { + return { type: partial.kind, required: false, ...partial }; +} + +describe('coerceValue', () => { + test('converts boolean text to a boolean', () => { + expect(coerceValue('boolean', 'true')).toBe(true); + expect(coerceValue('boolean', 'false')).toBe(false); + }); + + test('converts number text to a number', () => { + expect(coerceValue('number', '1.5')).toBe(1.5); + expect(coerceValue('number', '-3')).toBe(-3); + expect(coerceValue('number', '0')).toBe(0); + }); + + test('converts integer text to an integer', () => { + expect(coerceValue('integer', '42')).toBe(42); + expect(coerceValue('integer', '0')).toBe(0); + }); + + test('keeps the raw text when the value is not a valid number', () => { + expect(coerceValue('number', 'abc')).toBe('abc'); + expect(coerceValue('integer', '1.5')).toBe('1.5'); + }); + + test('keeps the raw text when the number cannot be represented', () => { + expect(coerceValue('number', '1e309')).toBe('1e309'); + expect(coerceValue('integer', '9007199254740993')).toBe('9007199254740993'); + }); + + test('keeps empty number text as empty', () => { + expect(coerceValue('number', '')).toBe(''); + expect(coerceValue('integer', ' ')).toBe(''); + }); + + test('keeps strings as strings', () => { + expect(coerceValue('string', 'true')).toBe('true'); + expect(coerceValue('string', '42')).toBe('42'); + }); +}); + +describe('arrayItemKind', () => { + test('reads the item kind from the array type', () => { + expect(arrayItemKind(field({ name: 'a', kind: 'array', type: 'boolean[]' }))).toBe('boolean'); + expect(arrayItemKind(field({ name: 'a', kind: 'array', type: 'integer(int64)[]' }))).toBe('integer'); + expect(arrayItemKind(field({ name: 'a', kind: 'array', type: 'string[]' }))).toBe('string'); + }); +}); + +describe('initBodyValues', () => { + test('sets booleans to false, arrays to [], objects to {}, and leaves other scalars unset', () => { + const fields = flattenSchema({ + type: 'object', + properties: { + active: { type: 'boolean' }, + tags: { type: 'array', items: { type: 'string' } }, + meta: { type: 'object', properties: { note: { type: 'string' } } }, + name: { type: 'string' }, + count: { type: 'integer' }, + }, + }); + expect(initBodyValues(fields)).toEqual({ active: false, tags: [], meta: {} }); + }); + + test('uses the schema default when one is given', () => { + const fields = flattenSchema({ + type: 'object', + properties: { + active: { type: 'boolean', default: true }, + count: { type: 'integer', default: 10 }, + }, + }); + expect(initBodyValues(fields)).toEqual({ active: true, count: 10 }); + }); + + test('sets nested object children, including booleans and defaults', () => { + const fields = flattenSchema({ + type: 'object', + properties: { + settings: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + limit: { type: 'integer', default: 5 }, + label: { type: 'string' }, + }, + }, + }, + }); + expect(initBodyValues(fields)).toEqual({ settings: { enabled: false, limit: 5 } }); + }); +}); + +describe('pruneBody', () => { + test('removes unset and empty values', () => { + expect(pruneBody({ a: undefined, b: '', c: [], d: {} })).toEqual({}); + }); + + test('keeps false and 0', () => { + expect(pruneBody({ active: false, count: 0 })).toEqual({ active: false, count: 0 }); + }); + + test('removes nested empty values and empty parents', () => { + expect(pruneBody({ meta: { note: '', inner: { x: undefined } }, name: 'n' })).toEqual({ name: 'n' }); + }); + + test('removes empty array items but keeps typed items', () => { + expect(pruneBody({ flags: [true, '', false], ids: [1, 0] })).toEqual({ flags: [true, false], ids: [1, 0] }); + }); + + test('does not send null for unset fields', () => { + const result = pruneBody({ name: undefined, count: '' }) as Record; + expect(JSON.stringify(result)).toBe('{}'); + }); +}); diff --git a/packages/chronicle/src/lib/playground-body.ts b/packages/chronicle/src/lib/playground-body.ts new file mode 100644 index 0000000..b132556 --- /dev/null +++ b/packages/chronicle/src/lib/playground-body.ts @@ -0,0 +1,57 @@ +import { type SchemaField, type SchemaFieldKind, toKind } from './schema' + +export function coerceValue(kind: SchemaFieldKind, raw: string): unknown { + if (kind === 'boolean') return raw === 'true' + if (kind === 'number' || kind === 'integer') { + if (raw.trim() === '') return '' + const n = Number(raw) + if (!Number.isFinite(n)) return raw + if (kind === 'integer' && !Number.isSafeInteger(n)) return raw + return n + } + return raw +} + +export function arrayItemKind(field: SchemaField): SchemaFieldKind { + const itemType = field.type.replace(/\[\]$/, '').replace(/\(.*\)$/, '') + return toKind(itemType) +} + +export function initFieldValue(field: SchemaField): unknown { + if (field.default !== undefined) return field.default + if (field.kind === 'array') return [] + if (field.kind === 'object' || field.children) return initBodyValues(field.children ?? []) + if (field.kind === 'boolean') return false + return undefined +} + +export function initBodyValues(fields: SchemaField[]): Record { + const init: Record = {} + for (const f of fields) { + const value = initFieldValue(f) + if (value !== undefined) init[f.name] = value + } + return init +} + +function isEmpty(value: unknown): boolean { + if (value === undefined || value === '') return true + if (Array.isArray(value)) return value.length === 0 + if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0 + return false +} + +export function pruneBody(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(pruneBody).filter((v) => !isEmpty(v)) + } + if (typeof value === 'object' && value !== null) { + const result: Record = {} + for (const [key, v] of Object.entries(value)) { + const pruned = pruneBody(v) + if (!isEmpty(pruned)) result[key] = pruned + } + return result + } + return value +}