Skip to content
Closed
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
5 changes: 4 additions & 1 deletion src/components/JsonViewer/JsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 (
<JsonViewerWrap>
Expand Down
3 changes: 2 additions & 1 deletion src/services/models/Example.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -46,7 +47,7 @@ export class ExampleModel {

if (isJsonLike(mimeType)) {
try {
return JSON.parse(txt);
return parseJsonPreservingBigInts(txt);
} catch (e) {
return txt;
}
Expand Down
39 changes: 39 additions & 0 deletions src/utils/__tests__/jsonToHtml.test.ts
Original file line number Diff line number Diff line change
@@ -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('<span class="token number">10765432100123456789</span>');
expect(html).toContain('<span class="token number">42</span>');
// 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('<span class="token number">10765432100123456789</span>');
});

it('keeps rendering plain numbers as before', () => {
expect(jsonToHTML(42, 1)).toContain('<span class="token number">42</span>');
expect(jsonToHTML(-7, 1)).toContain('<span class="token number">-7</span>');
expect(jsonToHTML(1.5, 1)).toContain('<span class="token number">1.5</span>');
});

it('keeps rendering null, boolean, string and object values', () => {
const html = jsonToHTML(null, 1);
expect(html).toContain('<span class="token keyword">null</span>');

const value = jsonToHTML({ ok: true, name: 'redoc', nested: { deep: 1 } }, 1);
expect(value).toContain('<span class="token boolean">true</span>');
expect(value).toContain('<span class="token string">&quot;redoc&quot;</span>');
expect(value).toContain('<span class="token number">1</span>');
});
});
71 changes: 71 additions & 0 deletions src/utils/__tests__/preciseJson.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 5 additions & 0 deletions src/utils/jsonToHtml.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { BigIntValue } from './preciseJson';

let level = 1;

export function jsonToHTML(json, maxExpandLevel) {
Expand Down Expand Up @@ -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);
Expand Down
126 changes: 126 additions & 0 deletions src/utils/preciseJson.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}