From 1a4f0fc8d1071c06d476a57f83b5413788548d68 Mon Sep 17 00:00:00 2001 From: ump45nose Date: Mon, 5 Oct 2026 04:22:50 +0800 Subject: [PATCH] feat(payload-samples): keep int64 values intact in samples JavaScript numbers are IEEE-754 doubles, so `JSON.parse` rounds away the low-order digits of any integer with more than 15-16 significant digits. An int64 value such as `10765432100123456789` was therefore shown as `10765432100123458000` in the response sample. External JSON examples are now parsed with a pass that rewrites integer literals a double cannot hold exactly into a `BigIntValue` that carries the digits from the source text, and the JSON viewer renders those digits as a number token. The wrapper also implements `toJSON`, so the copy button emits the exact digits instead of a silently rounded number, and the folding-button heuristic ignores it so it is not mistaken for a collapsible object. Values parsed elsewhere, for instance `example` inline in the spec, still arrive through `@redocly/openapi-core` before ReDoc sees them and are not covered. Closes: https://github.com/Redocly/redoc/issues/530 --- src/components/JsonViewer/JsonViewer.tsx | 5 +- src/services/models/Example.ts | 3 +- src/utils/__tests__/jsonToHtml.test.ts | 39 +++++++ src/utils/__tests__/preciseJson.test.ts | 71 +++++++++++++ src/utils/jsonToHtml.ts | 5 + src/utils/preciseJson.ts | 126 +++++++++++++++++++++++ 6 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/jsonToHtml.test.ts create mode 100644 src/utils/__tests__/preciseJson.test.ts create mode 100644 src/utils/preciseJson.ts diff --git a/src/components/JsonViewer/JsonViewer.tsx b/src/components/JsonViewer/JsonViewer.tsx index ba2738e85f..8efbbe1b09 100644 --- a/src/components/JsonViewer/JsonViewer.tsx +++ b/src/components/JsonViewer/JsonViewer.tsx @@ -5,6 +5,7 @@ import { SampleControls } from '../../common-elements'; import { CopyButtonWrapper } from '../../common-elements/CopyButtonWrapper'; import { PrismDiv } from '../../common-elements/PrismDiv'; import { jsonToHTML } from '../../utils/jsonToHtml'; +import { BigIntValue } from '../../utils/preciseJson'; import { OptionsContext } from '../OptionsProvider'; import { jsonStyles } from './style'; @@ -25,7 +26,9 @@ const Json = (props: JsonProps) => { const renderInner = ({ renderCopyButton }) => { const showFoldingButtons = props.data && - Object.values(props.data).some(value => typeof value === 'object' && value !== null); + Object.values(props.data).some( + value => typeof value === 'object' && value !== null && !(value instanceof BigIntValue), + ); return ( diff --git a/src/services/models/Example.ts b/src/services/models/Example.ts index b5f7e0ff97..daa3625044 100644 --- a/src/services/models/Example.ts +++ b/src/services/models/Example.ts @@ -1,4 +1,5 @@ import type { OpenAPIEncoding, OpenAPIExample, Referenced } from '../../types'; +import { parseJsonPreservingBigInts } from '../../utils/preciseJson'; import { isFormUrlEncoded, isJsonLike, urlFormEncodePayload } from '../../utils/openapi'; import type { OpenAPIParser } from '../OpenAPIParser'; @@ -46,7 +47,7 @@ export class ExampleModel { if (isJsonLike(mimeType)) { try { - return JSON.parse(txt); + return parseJsonPreservingBigInts(txt); } catch (e) { return txt; } diff --git a/src/utils/__tests__/jsonToHtml.test.ts b/src/utils/__tests__/jsonToHtml.test.ts new file mode 100644 index 0000000000..8f588c8fd2 --- /dev/null +++ b/src/utils/__tests__/jsonToHtml.test.ts @@ -0,0 +1,39 @@ +import { jsonToHTML } from '../jsonToHtml'; +import { parseJsonPreservingBigInts } from '../preciseJson'; + +describe('jsonToHTML', () => { + it('renders an int64 value with every digit', () => { + const value = parseJsonPreservingBigInts('{"id": 10765432100123456789, "small": 42}'); + + const html = jsonToHTML(value, 1); + + expect(html).toContain('10765432100123456789'); + expect(html).toContain('42'); + // the truncated form must not show up anywhere + expect(html).not.toContain('10765432100123458000'); + }); + + it('renders int64 values inside arrays', () => { + const value = parseJsonPreservingBigInts('{"ids": [10765432100123456789]}'); + + const html = jsonToHTML(value, 1); + + expect(html).toContain('10765432100123456789'); + }); + + it('keeps rendering plain numbers as before', () => { + expect(jsonToHTML(42, 1)).toContain('42'); + expect(jsonToHTML(-7, 1)).toContain('-7'); + expect(jsonToHTML(1.5, 1)).toContain('1.5'); + }); + + it('keeps rendering null, boolean, string and object values', () => { + const html = jsonToHTML(null, 1); + expect(html).toContain('null'); + + const value = jsonToHTML({ ok: true, name: 'redoc', nested: { deep: 1 } }, 1); + expect(value).toContain('true'); + expect(value).toContain('"redoc"'); + expect(value).toContain('1'); + }); +}); diff --git a/src/utils/__tests__/preciseJson.test.ts b/src/utils/__tests__/preciseJson.test.ts new file mode 100644 index 0000000000..11d7c2aaee --- /dev/null +++ b/src/utils/__tests__/preciseJson.test.ts @@ -0,0 +1,71 @@ +import { BigIntValue, parseJsonPreservingBigInts } from '../preciseJson'; + +describe('preciseJson', () => { + it('keeps integers beyond the safe range intact', () => { + const parsed = parseJsonPreservingBigInts('{"id": 10765432100123456789}'); + + expect(parsed.id).toBeInstanceOf(BigIntValue); + expect(parsed.id.text).toBe('10765432100123456789'); + }); + + it('keeps negative integers beyond the safe range intact', () => { + const parsed = parseJsonPreservingBigInts('{"id": -9223372036854775808}'); + + expect(parsed.id.text).toBe('-9223372036854775808'); + }); + + it('leaves integers a double can hold exactly as plain numbers', () => { + const parsed = parseJsonPreservingBigInts( + '{"small": 42, "maxSafe": 9007199254740991, "minSafe": -9007199254740991}', + ); + + expect(parsed.small).toBe(42); + expect(parsed.maxSafe).toBe(9007199254740991); + expect(parsed.minSafe).toBe(-9007199254740991); + }); + + it('works for values nested in objects and arrays', () => { + const parsed = parseJsonPreservingBigInts( + '{"data": {"rows": [{"ts": 10765432100123456789}, 1, "plain"]}}', + ); + + expect(parsed.data.rows[0].ts.text).toBe('10765432100123456789'); + expect(parsed.data.rows[1]).toBe(1); + expect(parsed.data.rows[2]).toBe('plain'); + }); + + it('does not touch digits inside string values', () => { + const parsed = parseJsonPreservingBigInts('{"note": "snowflake 10765432100123456789"}'); + + expect(parsed.note).toBe('snowflake 10765432100123456789'); + }); + + it('does not wrap floats or exponent numbers', () => { + const parsed = parseJsonPreservingBigInts('{"ratio": 1.5, "scaled": 1.5e3}'); + + expect(parsed.ratio).toBe(1.5); + expect(parsed.scaled).toBe(1500); + }); + + it('handles escaped quotes and backslashes around numbers', () => { + const parsed = parseJsonPreservingBigInts( + '{"q": "a \\"10765432100123456789\\" b", "id": 10765432100123456789}', + ); + + expect(parsed.q).toBe('a "10765432100123456789" b'); + expect(parsed.id.text).toBe('10765432100123456789'); + }); + + it('serializes back to JSON without losing digits', () => { + const parsed = parseJsonPreservingBigInts('{"id": 10765432100123456789, "small": 7}'); + + // the value is emitted as a JSON string, which keeps every digit + expect(JSON.stringify(parsed, null, 2)).toBe( + '{\n "id": "10765432100123456789",\n "small": 7\n}', + ); + }); + + it('rejects invalid JSON like JSON.parse does', () => { + expect(() => parseJsonPreservingBigInts('{"id": ')).toThrow(); + }); +}); diff --git a/src/utils/jsonToHtml.ts b/src/utils/jsonToHtml.ts index 3db6cd9159..4eb6145440 100644 --- a/src/utils/jsonToHtml.ts +++ b/src/utils/jsonToHtml.ts @@ -1,3 +1,5 @@ +import { BigIntValue } from './preciseJson'; + let level = 1; export function jsonToHTML(json, maxExpandLevel) { @@ -39,6 +41,9 @@ function valueToHTML(value, maxExpandLevel: number) { let output = ''; if (value === undefined || value === null) { output += decorateWithSpan('null', 'token keyword'); + } else if (value instanceof BigIntValue) { + // rendered from the source text: a double would have lost the low-order digits + output += decorateWithSpan(value.text, 'token number'); } else if (value && value.constructor === Array) { level++; output += arrayToHTML(value, maxExpandLevel); diff --git a/src/utils/preciseJson.ts b/src/utils/preciseJson.ts new file mode 100644 index 0000000000..3cb7ef08f8 --- /dev/null +++ b/src/utils/preciseJson.ts @@ -0,0 +1,126 @@ +/** + * A JSON integer that a JavaScript number cannot represent exactly. + * + * JavaScript numbers are IEEE-754 doubles, so `JSON.parse` silently rounds any integer + * with more than 15-16 significant digits: `10765432100123456789` comes back as + * `10765432100123458000`. Carrying the digits from the source text is the only way to + * render such a value correctly, which is what this wrapper is for. + */ +export class BigIntValue { + constructor(public readonly text: string) {} + + /** + * Picked up by `JSON.stringify` (for instance the sample copy button), so a copied + * payload keeps every digit instead of a silently rounded number. + */ + toJSON(): string { + return this.text; + } +} + +/** + * A NUL byte cannot appear literally inside a JSON string, so this prefix cannot clash + * with data that the document author actually wrote. + */ +const SENTINEL = '\u0000bigint:'; + +/** The same prefix, as it has to be written into the text handed to `JSON.parse`. */ +const SENTINEL_ESCAPED = '\\u0000bigint:'; + +function isDigit(char: string | undefined): boolean { + return char !== undefined && char >= '0' && char <= '9'; +} + +/** Reads one complete JSON number token: integer part, optional fraction and exponent. */ +function readNumberToken(json: string, start: number): { token: string; next: number } { + let i = start; + if (json[i] === '-') { + i++; + } + while (isDigit(json[i])) { + i++; + } + if (json[i] === '.') { + i++; + while (isDigit(json[i])) { + i++; + } + } + if (json[i] === 'e' || json[i] === 'E') { + i++; + if (json[i] === '+' || json[i] === '-') { + i++; + } + while (isDigit(json[i])) { + i++; + } + } + return { token: json.slice(start, i), next: i }; +} + +/** + * Rewrites integer literals that a double cannot hold exactly into strings, so the + * following `JSON.parse` hands them back as {@link BigIntValue}. + * + * The text has to be walked by hand because a regular expression would also match the + * digits of a string value (`"snowflake 10765432100123456789"`), and the digits cannot + * be recovered once `JSON.parse` has already rounded them away. + */ +function protectUnsafeIntegers(json: string): string { + let output = ''; + let i = 0; + let inString = false; + + while (i < json.length) { + const char = json[i]; + + if (inString) { + if (char === '\\') { + output += json.slice(i, i + 2); + i += 2; + continue; + } + if (char === '"') { + inString = false; + } + output += char; + i++; + continue; + } + + if (char === '"') { + inString = true; + output += char; + i++; + continue; + } + + if (char === '-' || isDigit(char)) { + const { token, next } = readNumberToken(json, i); + const isPlainInteger = token.search(/[.eE]/) === -1; + output += + isPlainInteger && !Number.isSafeInteger(Number(token)) + ? `"${SENTINEL_ESCAPED}${token}"` + : token; + i = next; + continue; + } + + output += char; + i++; + } + + return output; +} + +/** + * Parses JSON text keeping integers beyond the safe range intact, so consumers can + * render and copy them with every digit preserved. + */ +export function parseJsonPreservingBigInts(json: string): any { + return JSON.parse(protectUnsafeIntegers(json), (_key, value) => + typeof value === 'string' && value.indexOf(SENTINEL) === 0 + ? new BigIntValue(value.slice(SENTINEL.length)) + : value, + ); +}