From 0b5c09f47c4e682c2de69006c5519b27cf7199bc Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 14 Oct 2026 11:24:49 +0530 Subject: [PATCH 1/2] fix: send typed values from the test request form Boolean, number and integer body fields were sent as strings. Boolean fields now use a switch, number fields convert the text to a number, and array items convert by their item type. Unset fields are removed from the body instead of sent as empty strings. Also add jsonMode and bodyJsonStr to the send callback dependencies, so JSON mode does not send an old body. Co-Authored-By: Claude Opus 5.5 --- .../src/components/api/playground-dialog.tsx | 86 +++++++++------- .../chronicle/src/lib/playground-body.test.ts | 98 +++++++++++++++++++ packages/chronicle/src/lib/playground-body.ts | 57 +++++++++++ 3 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 packages/chronicle/src/lib/playground-body.test.ts create mode 100644 packages/chronicle/src/lib/playground-body.ts 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..1765b7f --- /dev/null +++ b/packages/chronicle/src/lib/playground-body.test.ts @@ -0,0 +1,98 @@ +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 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 }); + }); +}); + +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..b429801 --- /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.isNaN(n)) return raw + if (kind === 'integer' && !Number.isInteger(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 {} + 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 +} From 70145b8c35a4841d962bad09f9825aa3b85a542f Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 14 Oct 2026 11:33:24 +0530 Subject: [PATCH 2/2] fix: keep unsafe numbers as text and set nested defaults Numbers that are not finite, and integers outside the safe range, now stay as raw text, so the request does not send a rounded value or null. Object fields now set their children on start, so a nested boolean is sent as false or its schema default. Co-Authored-By: Claude Opus 5.5 --- .../chronicle/src/lib/playground-body.test.ts | 22 +++++++++++++++++++ packages/chronicle/src/lib/playground-body.ts | 6 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/chronicle/src/lib/playground-body.test.ts b/packages/chronicle/src/lib/playground-body.test.ts index 1765b7f..61e1167 100644 --- a/packages/chronicle/src/lib/playground-body.test.ts +++ b/packages/chronicle/src/lib/playground-body.test.ts @@ -28,6 +28,11 @@ describe('coerceValue', () => { 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(''); @@ -72,6 +77,23 @@ describe('initBodyValues', () => { }); 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', () => { diff --git a/packages/chronicle/src/lib/playground-body.ts b/packages/chronicle/src/lib/playground-body.ts index b429801..b132556 100644 --- a/packages/chronicle/src/lib/playground-body.ts +++ b/packages/chronicle/src/lib/playground-body.ts @@ -5,8 +5,8 @@ export function coerceValue(kind: SchemaFieldKind, raw: string): unknown { if (kind === 'number' || kind === 'integer') { if (raw.trim() === '') return '' const n = Number(raw) - if (Number.isNaN(n)) return raw - if (kind === 'integer' && !Number.isInteger(n)) return raw + if (!Number.isFinite(n)) return raw + if (kind === 'integer' && !Number.isSafeInteger(n)) return raw return n } return raw @@ -20,7 +20,7 @@ export function arrayItemKind(field: SchemaField): SchemaFieldKind { 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 {} + if (field.kind === 'object' || field.children) return initBodyValues(field.children ?? []) if (field.kind === 'boolean') return false return undefined }