Skip to content

Commit 680c01f

Browse files
authored
feat: Add support for canonical JSON serialization. (#241)
This adds a canonical JSON serializer which can be used for implementing per-context summary events. Testing starts with all the cases from the JavaScript implementation, and then it was extended with dart/flutter concerns. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Primarily additive functionality and tests; main risk is subtle cross-platform numeric/string canonicalization differences, mitigated by extensive coverage and a Chrome CI run. > > **Overview** > Introduces `canonicalizeJson` (RFC 8785 canonical JSON) to `packages/common`, producing deterministic, compact JSON by sorting object keys, normalizing number formatting (including scientific notation and trimming trailing zeros), and rejecting cycles; strict mode errors on NaN/Infinity with an optional lenient mode that replaces them with `null`. > > Adds comprehensive unit tests (including golden test vectors) plus a dedicated platform-parity test suite, and updates CI to run the canonicalization tests on Chrome to catch Dart VM vs JavaScript serialization differences early. The new API is exported from `launchdarkly_dart_common.dart` for downstream use. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5de41dc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0dd2106 commit 680c01f

18 files changed

Lines changed: 1007 additions & 0 deletions

.github/actions/ci/action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ runs:
5454
shell: bash
5555
run: melos run test
5656

57+
- name: Platform Canonicalization Tests (Chrome)
58+
shell: bash
59+
# Run RFC 8785 canonicalization tests on Chrome to verify platform-independent behavior.
60+
# These tests ensure that the canonicalization produces identical output on both
61+
# Dart VM (tested above via melos run test) and Dart2JS/JavaScript (tested here).
62+
run: |
63+
cd packages/common
64+
dart test test/serialization/canonicalize_json_platform_test.dart -p chrome --test-randomize-ordering-seed=random
65+
5766
- name: Contract Tests
5867
shell: bash
5968
run: |

packages/common/lib/launchdarkly_dart_common.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export 'src/serialization/ld_evaluation_results_serialization.dart'
2828
show LDEvaluationResultsSerialization;
2929
export 'src/serialization/ld_context_serialization.dart'
3030
show LDContextSerialization;
31+
export 'src/serialization/canonicalize_json.dart' show canonicalizeJson;
3132
export 'src/serialization/event_serialization.dart'
3233
show
3334
IdentifyEventSerialization,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import 'dart:convert';
2+
3+
/// Serialize a number according to RFC 8785 rules.
4+
///
5+
/// Per RFC 8785, NaN and Infinity are forbidden and implementations
6+
/// must terminate with an error if encountered. However, when [lenient]
7+
/// is true, these values are replaced with null instead.
8+
String _serializeNumber(num value, {required bool lenient}) {
9+
// RFC 8785 requires termination with error for NaN and Infinity
10+
// In lenient mode, replace with null instead
11+
if (value.isNaN) {
12+
if (lenient) {
13+
return 'null';
14+
}
15+
throw ArgumentError('NaN is not allowed in RFC 8785 canonical JSON');
16+
}
17+
if (value.isInfinite) {
18+
if (lenient) {
19+
return 'null';
20+
}
21+
throw ArgumentError('Infinity is not allowed in RFC 8785 canonical JSON');
22+
}
23+
24+
// Check if it's an integer value (even if stored as double)
25+
// Per RFC 8785/ECMA-262: integers with magnitude >= 10^21 must use
26+
// scientific notation, not plain decimal. Only convert to int if the
27+
// value is small enough for plain decimal representation.
28+
const maxPlainDecimal = 1e21;
29+
if (value == value.toInt() && value.abs() < maxPlainDecimal) {
30+
return value.toInt().toString();
31+
}
32+
33+
// For non-integer values or large integers, use Dart's toString()
34+
// Dart's num.toString() returns the shortest string that uniquely identifies
35+
// the number, using exponential notation outside the range 10^-6 to 10^21.
36+
// On web platforms, Dart defers to JavaScript's number serialization.
37+
// See: https://api.dart.dev/dart-core/num/toString.html
38+
String str = value.toString();
39+
40+
// RFC 8785 requires lowercase 'e' in scientific notation
41+
// Dart may produce uppercase 'E', so normalize it
42+
if (str.contains('E')) {
43+
str = str.replaceAll('E', 'e');
44+
}
45+
46+
// Remove unnecessary trailing zeros after decimal point
47+
// (but only if not in scientific notation)
48+
if (str.contains('.') && !str.contains('e')) {
49+
str = str.replaceAll(RegExp(r'\.?0+$'), '');
50+
}
51+
52+
return str;
53+
}
54+
55+
/// Internal implementation of canonicalize that tracks visited objects.
56+
String _canonicalizeJson(dynamic object,
57+
{required bool lenient, List<dynamic> visited = const []}) {
58+
// Handle null
59+
if (object == null) {
60+
return 'null';
61+
}
62+
63+
// Handle primitives
64+
if (object is num) {
65+
return _serializeNumber(object, lenient: lenient);
66+
}
67+
68+
if (object is bool) {
69+
return object.toString();
70+
}
71+
72+
if (object is String) {
73+
return jsonEncode(object);
74+
}
75+
76+
// Check for cycles
77+
if (visited.contains(object)) {
78+
throw ArgumentError('Cycle detected');
79+
}
80+
81+
// Handle arrays
82+
if (object is List) {
83+
final newVisited = [...visited, object];
84+
final values = object.map((item) =>
85+
_canonicalizeJson(item, lenient: lenient, visited: newVisited));
86+
return '[${values.join(',')}]';
87+
}
88+
89+
// Handle objects/maps
90+
if (object is Map) {
91+
final newVisited = [...visited, object];
92+
93+
// Create a list of key-value pairs with string keys for sorting
94+
final entries = object.entries.map((entry) {
95+
final keyStr = entry.key.toString();
96+
return MapEntry(keyStr, entry);
97+
}).toList()
98+
..sort((a, b) => a.key.compareTo(b.key));
99+
100+
final serializedValues = entries.map((entry) {
101+
final keyStr = entry.key;
102+
final originalValue = entry.value.value;
103+
final value = _canonicalizeJson(originalValue,
104+
lenient: lenient, visited: newVisited);
105+
// Include the key-value pair only if the value is not undefined
106+
// (In Dart, we don't have undefined, so we include all values)
107+
return '${jsonEncode(keyStr)}:$value';
108+
});
109+
110+
return '{${serializedValues.join(',')}}';
111+
}
112+
113+
// For any other object type, we can't serialize it
114+
throw ArgumentError(
115+
'Cannot canonicalize object of type ${object.runtimeType}');
116+
}
117+
118+
/// Given some object to serialize, produce a canonicalized JSON string
119+
/// according to RFC 8785 (https://www.rfc-editor.org/rfc/rfc8785.html).
120+
///
121+
/// We do not support custom toJson methods on objects. Objects should be
122+
/// limited to basic types.
123+
///
124+
/// When [lenient] is false (default), throws an [ArgumentError] if NaN or
125+
/// Infinity is encountered, per RFC 8785 requirements. When [lenient] is
126+
/// true, NaN and Infinity are replaced with null for safety.
127+
///
128+
/// Throws an [ArgumentError] if a cycle is detected in the object graph.
129+
String canonicalizeJson(dynamic object, {bool lenient = false}) {
130+
return _canonicalizeJson(object, lenient: lenient);
131+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Platform-independent canonicalization tests for RFC 8785.
2+
//
3+
// These tests verify that JSON canonicalization produces identical results
4+
// on both Dart VM (native) and Dart2JS (web) platforms.
5+
//
6+
// In CI, these tests run on both platforms:
7+
// - VM platform: via `melos run test`
8+
// - Chrome platform: via dedicated CI step (see .github/actions/ci/action.yml)
9+
//
10+
// To run locally on both platforms:
11+
// dart test test/serialization/canonicalize_json_platform_test.dart -p vm,chrome
12+
13+
import 'dart:convert';
14+
import 'package:launchdarkly_dart_common/src/serialization/canonicalize_json.dart';
15+
import 'package:test/test.dart';
16+
17+
void main() {
18+
group('Platform-independent canonicalization', () {
19+
// These tests verify that canonicalization produces identical results
20+
// on both Dart VM (native) and Dart2JS (web) platforms
21+
22+
final testCases = <String, dynamic>{
23+
// Large numbers that could expose platform differences
24+
'1e30': 1e30,
25+
'1e21': 1e21,
26+
'1e-27': 1e-27,
27+
28+
// Integer-valued doubles
29+
'56': 56.0,
30+
'42': 42,
31+
'0': 0.0,
32+
'-100': -100.0,
33+
34+
// Fractional numbers
35+
'3.14': 3.14,
36+
'4.5': 4.50, // Should remove trailing zero
37+
'0.002': 2e-3,
38+
39+
// Objects with mixed numeric types
40+
'object with numbers': {
41+
'int': 42,
42+
'double': 3.14,
43+
'large': 1e30,
44+
'small': 1e-27,
45+
'zero': 0.0,
46+
},
47+
48+
// Arrays with various number types
49+
'array of numbers': [1, 2.5, 1e10, 1e-5, 0],
50+
51+
// Edge cases
52+
'negative zero': -0.0,
53+
'negative large': -1e30,
54+
55+
// Complex nested structure
56+
'complex': {
57+
'z': 99,
58+
'a': {
59+
'nested': 1e20,
60+
'values': [1, 2.5, 3.0],
61+
},
62+
'm': true,
63+
}
64+
};
65+
66+
// Expected outputs (platform-independent)
67+
final expectedOutputs = <String, String>{
68+
'1e30': '1e+30',
69+
'1e21': '1e+21',
70+
'1e-27': '1e-27',
71+
'56': '56',
72+
'42': '42',
73+
'0': '0',
74+
'-100': '-100',
75+
'3.14': '3.14',
76+
'4.5': '4.5',
77+
'0.002': '0.002',
78+
'object with numbers':
79+
'{"double":3.14,"int":42,"large":1e+30,"small":1e-27,"zero":0}',
80+
'array of numbers': '[1,2.5,10000000000,0.00001,0]',
81+
'negative zero': '0',
82+
'negative large': '-1e+30',
83+
'complex':
84+
'{"a":{"nested":100000000000000000000,"values":[1,2.5,3]},"m":true,"z":99}',
85+
};
86+
87+
for (var entry in testCases.entries) {
88+
test('${entry.key} produces platform-independent output', () {
89+
final result = canonicalizeJson(entry.value);
90+
final expected = expectedOutputs[entry.key];
91+
92+
expect(result, equals(expected),
93+
reason:
94+
'Value ${entry.key} should produce the same output on all platforms');
95+
96+
// Verify it's valid JSON by round-tripping
97+
expect(() => jsonDecode(result), returnsNormally,
98+
reason: 'Output should be valid JSON');
99+
});
100+
}
101+
102+
test('large integer double (1.0) representation', () {
103+
// This test specifically checks behavior that could differ between platforms
104+
// On native: 1.0.toString() -> "1.0"
105+
// On web: 1.0.toString() -> "1"
106+
// Our canonicalization should always produce "1"
107+
108+
final result = canonicalizeJson(1.0);
109+
expect(result, equals('1'),
110+
reason: 'Integer-valued doubles should not include decimal point');
111+
});
112+
113+
test('scientific notation normalization', () {
114+
// Dart might produce "1E+30" on some platforms
115+
// We normalize to lowercase "1e+30"
116+
117+
final result = canonicalizeJson(1e30);
118+
expect(result, equals('1e+30'));
119+
expect(result, isNot(contains('E')),
120+
reason: 'Should use lowercase e, not uppercase E');
121+
});
122+
123+
test('complex structure produces deterministic output', () {
124+
final obj = {
125+
'numbers': [1e30, 56.0, 3.14],
126+
'nested': {
127+
'z': 'last',
128+
'a': 'first',
129+
},
130+
};
131+
132+
// Should be the same on all platforms
133+
final result = canonicalizeJson(obj);
134+
expect(
135+
result,
136+
equals(
137+
'{"nested":{"a":"first","z":"last"},"numbers":[1e+30,56,3.14]}'));
138+
});
139+
140+
test('lenient mode works consistently across platforms', () {
141+
final result1 = canonicalizeJson(double.nan, lenient: true);
142+
expect(result1, equals('null'));
143+
144+
final result2 = canonicalizeJson(double.infinity, lenient: true);
145+
expect(result2, equals('null'));
146+
147+
final result3 = canonicalizeJson(double.negativeInfinity, lenient: true);
148+
expect(result3, equals('null'));
149+
});
150+
});
151+
}

0 commit comments

Comments
 (0)