Skip to content

Commit 67f4ff3

Browse files
authored
Merge pull request #27 from geobrowser/chris.whited/feat-canonicalize
feat(canonicalization): create RFC8785 compliant JSC canonicalization function
2 parents 84ab5fd + 795475f commit 67f4ff3

File tree

5 files changed

+200
-10
lines changed

5 files changed

+200
-10
lines changed

packages/graph-framework-utils/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,24 @@ const decoded = decodeBase58ToUUID(encoded);
4343
expect(encoded).toHaveLength(22);
4444
expect(decoded).toEqual(uuid);
4545
```
46+
47+
### JSC utils
48+
49+
- `canonicalize` - JSON canonicalize function. Creates crypto safe predictable canocalization of JSON as defined by RFC8785.
50+
51+
```ts
52+
import { canonicalize } from 'graph-framework-utils'
53+
54+
console.log(canonicalize(null)) // 'null'
55+
console.log(canonicalize(1)) // '1'
56+
console.log(canonicalize("test")) // "string"
57+
console.log(canonicalize(true)) // 'true'
58+
const json = {
59+
from_account: '543 232 625-3',
60+
to_account: '321 567 636-4',
61+
amount: 500,
62+
currency: 'USD',
63+
};
64+
console.log(canonicalize(json)) // '{"amount":500,"currency":"USD","from_account":"543 232 625-3","to_account":"321 567 636-4"}'
65+
console.log(canonicalize([1, 'text', null, true, false])) // '[1,"text",null,true,false]'
66+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './base58.js';
22
export * from './generateId.js';
3+
export * from './jsc.js';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { InfinityNotAllowedError, NaNNotAllowedError, canonicalize } from './jsc.js';
4+
5+
describe('jsc', () => {
6+
describe('canonicalize', () => {
7+
it('should handle primitive values', () => {
8+
expect(canonicalize(null)).toBe('null');
9+
expect(canonicalize(true)).toBe('true');
10+
expect(canonicalize(false)).toBe('false');
11+
expect(canonicalize(123)).toBe('123');
12+
expect(canonicalize('string')).toBe('"string"');
13+
});
14+
it('should canonizalize the given object using RFC8785 and maintain order for complex object', () => {
15+
const json = {
16+
1: { f: { f: 'hi', F: 5 }, '\n': 56.0 },
17+
10: {},
18+
'': 'empty',
19+
a: {},
20+
111: [
21+
{
22+
e: 'yes',
23+
E: 'no',
24+
},
25+
],
26+
A: {},
27+
};
28+
const actual = canonicalize(json);
29+
30+
expect(actual).toEqual(
31+
'{"":"empty","1":{"\\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}}',
32+
);
33+
});
34+
it('should canonicalize a simple JSON object', () => {
35+
const json = {
36+
from_account: '543 232 625-3',
37+
to_account: '321 567 636-4',
38+
amount: 500,
39+
currency: 'USD',
40+
};
41+
expect(canonicalize(json)).toEqual(
42+
'{"amount":500,"currency":"USD","from_account":"543 232 625-3","to_account":"321 567 636-4"}',
43+
);
44+
});
45+
it('should handle empty array', () => {
46+
expect(canonicalize([])).toBe('[]');
47+
});
48+
it('should handle array with various types', () => {
49+
expect(canonicalize([1, 'text', null, true, false])).toBe('[1,"text",null,true,false]');
50+
});
51+
it('should ignore undefined and symbol values in arrays', () => {
52+
expect(canonicalize([1, undefined, Symbol('symbol'), 2])).toBe('[1,2]');
53+
});
54+
it('should handle empty object', () => {
55+
expect(canonicalize({})).toBe('{}');
56+
});
57+
it('should handle object with sorted keys', () => {
58+
const obj = { b: 2, a: 1 };
59+
expect(canonicalize(obj)).toBe('{"a":1,"b":2}');
60+
});
61+
it('should ignore undefined and symbol values in objects', () => {
62+
const obj = { a: 1, b: undefined, c: Symbol('symbol'), d: 2 };
63+
expect(canonicalize(obj)).toBe('{"a":1,"d":2}');
64+
});
65+
it('should handle nested objects and arrays', () => {
66+
const obj = { b: [3, 2, { c: 1 }], a: { x: 'y' } };
67+
expect(canonicalize(obj)).toBe('{"a":{"x":"y"},"b":[3,2,{"c":1}]}');
68+
});
69+
it('should handle objects with toJSON method', () => {
70+
const obj = {
71+
toJSON: () => ({ a: 1, b: 2 }),
72+
};
73+
expect(canonicalize(obj)).toBe('{"a":1,"b":2}');
74+
});
75+
it('should throw NaNNotAllowedError for NaN values', () => {
76+
expect(() => canonicalize(Number.NaN)).toThrow(NaNNotAllowedError);
77+
});
78+
it('should throw an error if given an infinite number', () => {
79+
expect(() => {
80+
canonicalize(Number.POSITIVE_INFINITY);
81+
}).toThrow(new InfinityNotAllowedError());
82+
});
83+
});
84+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
export class NaNNotAllowedError extends Error {
2+
constructor() {
3+
super('NaN is not allowed');
4+
}
5+
}
6+
export class InfinityNotAllowedError extends Error {
7+
constructor() {
8+
super('Infinity is not allowed');
9+
}
10+
}
11+
12+
/**
13+
* JSON canonicalize function.
14+
* Creates crypto safe predictable canocalization of JSON as defined by RFC8785.
15+
*
16+
* @see https://tools.ietf.org/html/rfc8785
17+
* @see https://www.rfc-editor.org/rfc/rfc8785
18+
*
19+
* @example <caption>Primitive values</caption>
20+
* ```ts
21+
* import { canonicalize } from 'graph-framework-utils'
22+
*
23+
* console.log(canonicalize(null)) // 'null'
24+
* console.log(canonicalize(1)) // '1'
25+
* console.log(canonicalize("test")) // "string"
26+
* console.log(canonicalize(true)) // 'true'
27+
* ```
28+
*
29+
* @example <caption>Objects</caption>
30+
* ```
31+
* import { canonicalize } from 'graph-framework-utils'
32+
*
33+
* const json = {
34+
* from_account: '543 232 625-3',
35+
* to_account: '321 567 636-4',
36+
* amount: 500,
37+
* currency: 'USD',
38+
* };
39+
* console.log(canonicalize(json)) // '{"amount":500,"currency":"USD","from_account":"543 232 625-3","to_account":"321 567 636-4"}'
40+
* ```
41+
*
42+
* @example <caption>Arrays</caption>
43+
* ```ts
44+
* import { canonicalize } from 'graph-framework-utils'
45+
*
46+
* console.log(canonicalize([1, 'text', null, true, false])) // '[1,"text",null,true,false]'
47+
* ```
48+
*
49+
* @param object object to JSC canonicalize
50+
* @throws NaNNotAllowedError if given object is of type number, but is not a valid number
51+
* @throws InfinityNotAllowedError if given object is of type number, but is the infinite number
52+
*/
53+
export function canonicalize<T = unknown>(object: T): string {
54+
if (typeof object === 'number' && Number.isNaN(object)) {
55+
throw new NaNNotAllowedError();
56+
}
57+
if (typeof object === 'number' && !Number.isFinite(object)) {
58+
throw new InfinityNotAllowedError();
59+
}
60+
61+
if (object === null || typeof object !== 'object') {
62+
return JSON.stringify(object);
63+
}
64+
65+
// biome-ignore lint/suspicious/noExplicitAny: typeof T is unknown, cast to any to check
66+
if ((object as any).toJSON instanceof Function) {
67+
// biome-ignore lint/suspicious/noExplicitAny: typeof T is unknown, cast to any to check
68+
return canonicalize((object as any).toJSON());
69+
}
70+
71+
if (Array.isArray(object)) {
72+
const values = object.reduce((t, cv) => {
73+
if (cv === undefined || typeof cv === 'symbol') {
74+
return t; // Skip undefined and symbol values entirely
75+
}
76+
const comma = t.length === 0 ? '' : ',';
77+
return `${t}${comma}${canonicalize(cv)}`;
78+
}, '');
79+
return `[${values}]`;
80+
}
81+
82+
const values = Object.keys(object)
83+
.sort()
84+
.reduce((t, cv) => {
85+
// biome-ignore lint/suspicious/noExplicitAny: typeof T is unknown, cast to any to check
86+
if ((object as any)[cv] === undefined || typeof (object as any)[cv] === 'symbol') {
87+
return t;
88+
}
89+
const comma = t.length === 0 ? '' : ',';
90+
// biome-ignore lint/suspicious/noExplicitAny: typeof T is unknown, cast to any to check
91+
return `${t}${comma}${canonicalize(cv)}:${canonicalize((object as any)[cv])}`;
92+
}, '');
93+
return `{${values}}`;
94+
}

vitest.workspace.js

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)