Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 50 additions & 36 deletions packages/chronicle/src/components/api/playground-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -143,16 +144,7 @@ export function PlaygroundDialog({
const [pathValues, setPathValues] = useState<Record<string, string>>({})
const [queryValues, setQueryValues] = useState<Record<string, string>>({})
const [jsonMode, setJsonMode] = useState(false)
const [bodyValues, setBodyValues] = useState<Record<string, unknown>>(() => {
if (!body) return {}
const init: Record<string, unknown> = {}
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<Record<string, unknown>>(() => body ? initBodyValues(body.fields) : {})
const [bodyJsonStr, setBodyJsonStr] = useState(() => body ? body.jsonExample : '{}')

useEffect(() => {
Expand Down Expand Up @@ -200,16 +192,7 @@ export function PlaygroundDialog({
setHeaderValues({})
setPathValues({})
setQueryValues({})
setBodyValues(() => {
if (!body) return {}
const init: Record<string, unknown> = {}
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)
}

Expand Down Expand Up @@ -244,7 +227,7 @@ export function PlaygroundDialog({
return
}
} else {
reqBody = bodyValues
reqBody = pruneBody(bodyValues)
}
}

Expand All @@ -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))
Expand All @@ -275,7 +258,7 @@ export function PlaygroundDialog({
const curlSnippet = useMemo(() => {
const headers: Record<string, string> = { ...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])

Expand Down Expand Up @@ -428,7 +411,7 @@ export function PlaygroundDialog({
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--rs-space-3)' }}>
<IconButton size={2} onClick={() => {
if (!jsonMode) {
setBodyJsonStr(JSON.stringify(bodyValues, null, 2))
setBodyJsonStr(JSON.stringify(pruneBody(bodyValues), null, 2))
} else {
try { setBodyValues(JSON.parse(bodyJsonStr)) } catch { /* ignore */ }
}
Expand Down Expand Up @@ -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 (
<div className={styles.arrayField}>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{field.name} {field.required && <Badge variant="danger" size="micro">required</Badge>}</span>
<IconButton size={2} onClick={() => onChange([...items, ''])} aria-label="Add item">
<IconButton size={2} onClick={() => onChange([...items, itemKind === 'boolean' ? false : ''])} aria-label="Add item">
<PlusIcon />
</IconButton>
</div>
{items.map((item, i) => (
<div key={i} className={styles.arrayItemRow}>
<div className={styles.fieldInput}>
<Input
size="small"
<ScalarInput
kind={itemKind}
placeholder={`${field.name}[${i}]`}
value={String(item)}
onValueChange={(v) => {
value={item}
onChange={(v) => {
const updated = [...items]
updated[i] = v
onChange(updated)
Expand Down Expand Up @@ -618,17 +602,47 @@ function BodyFieldRow({ field, value, onChange }: {
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{field.name} {field.required && <Badge variant="danger" size="micro">required</Badge>}</span>
<div className={styles.fieldInput}>
<Input
size="small"
<ScalarInput
kind={field.kind}
placeholder={field.description ?? 'Enter value'}
value={String(value ?? '')}
onValueChange={(v) => onChange(v)}
value={value}
onChange={onChange}
/>
</div>
</div>
)
}

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 <Switch size="small" checked={value === true} onCheckedChange={(checked) => onChange(checked)} />
}

return (
<Input
size="small"
type={kind === 'number' || kind === 'integer' ? 'number' : undefined}
placeholder={placeholder}
value={text}
onValueChange={(v) => {
setText(v)
onChange(coerceValue(kind, v))
}}
/>
)
}

function paramsToFields(params: OpenAPIV3.ParameterObject[]): SchemaField[] {
return params.map((p) => {
const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject
Expand Down
120 changes: 120 additions & 0 deletions packages/chronicle/src/lib/playground-body.test.ts
Original file line number Diff line number Diff line change
@@ -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<SchemaField> & Pick<SchemaField, 'name' | 'kind'>): 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<string, unknown>;
expect(JSON.stringify(result)).toBe('{}');
});
});
57 changes: 57 additions & 0 deletions packages/chronicle/src/lib/playground-body.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const init: Record<string, unknown> = {}
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<string, unknown> = {}
for (const [key, v] of Object.entries(value)) {
const pruned = pruneBody(v)
if (!isEmpty(pruned)) result[key] = pruned
}
return result
}
return value
}
Loading