Skip to content

Commit 6765f35

Browse files
fix(idempotency): deep sort payload during hashing (#2570)
Co-authored-by: Andrea Amorosi <[email protected]>
1 parent f958d52 commit 6765f35

File tree

3 files changed

+206
-2
lines changed

3 files changed

+206
-2
lines changed

Diff for: packages/idempotency/src/deepSort.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { getType } from '@aws-lambda-powertools/commons';
2+
import {
3+
JSONArray,
4+
JSONObject,
5+
JSONValue,
6+
} from '@aws-lambda-powertools/commons/types';
7+
8+
/**
9+
* Sorts the keys of a provided object in a case-insensitive manner.
10+
*
11+
* This function takes an object as input, sorts its keys alphabetically without
12+
* considering case sensitivity and recursively sorts any nested objects or arrays.
13+
*
14+
* @param {JSONObject} object - The JSON object to be sorted.
15+
* @returns {JSONObject} - A new JSON object with all keys sorted alphabetically in a case-insensitive manner.
16+
*/
17+
const sortObject = (object: JSONObject): JSONObject =>
18+
Object.keys(object)
19+
.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1))
20+
.reduce((acc, key) => {
21+
acc[key] = deepSort(object[key]);
22+
23+
return acc;
24+
}, {} as JSONObject);
25+
26+
/**
27+
* Recursively sorts the keys of an object or elements of an array.
28+
*
29+
* This function sorts the keys of any JSON in a case-insensitive manner and recursively applies the same sorting to
30+
* nested objects and arrays. Primitives (strings, numbers, booleans, null) are returned unchanged.
31+
*
32+
* @param {JSONValue} data - The input data to be sorted, which can be an object, array or primitive value.
33+
* @returns {JSONValue} - The sorted data, with all object's keys sorted alphabetically in a case-insensitive manner.
34+
*/
35+
const deepSort = (data: JSONValue): JSONValue => {
36+
const type = getType(data);
37+
if (type === 'object') {
38+
return sortObject(data as JSONObject);
39+
} else if (type === 'array') {
40+
return (data as JSONArray).map(deepSort);
41+
}
42+
43+
return data;
44+
};
45+
46+
export { deepSort };

Diff for: packages/idempotency/src/persistence/BasePersistenceLayer.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../errors.js';
1616
import { LRUCache } from './LRUCache.js';
1717
import type { JSONValue } from '@aws-lambda-powertools/commons/types';
18+
import { deepSort } from '../deepSort.js';
1819

1920
/**
2021
* Base class for all persistence layers. This class provides the basic functionality for
@@ -301,7 +302,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
301302
}
302303

303304
return `${this.idempotencyKeyPrefix}#${this.generateHash(
304-
JSON.stringify(data)
305+
JSON.stringify(deepSort(data))
305306
)}`;
306307
}
307308

@@ -318,7 +319,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
318319
this.#jmesPathOptions
319320
) as JSONValue;
320321

321-
return this.generateHash(JSON.stringify(data));
322+
return this.generateHash(JSON.stringify(deepSort(data)));
322323
} else {
323324
return '';
324325
}

Diff for: packages/idempotency/tests/unit/deepSort.test.ts

+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Test deepSort Function
3+
*
4+
* @group unit/idempotency/deepSort
5+
*/
6+
import { deepSort } from '../../src/deepSort';
7+
8+
describe('Function: deepSort', () => {
9+
test('can sort string correctly', () => {
10+
expect(deepSort('test')).toEqual('test');
11+
});
12+
13+
test('can sort number correctly', () => {
14+
expect(deepSort(5)).toEqual(5);
15+
});
16+
17+
test('can sort boolean correctly', () => {
18+
expect(deepSort(true)).toEqual(true);
19+
expect(deepSort(false)).toEqual(false);
20+
});
21+
22+
test('can sort null correctly', () => {
23+
expect(deepSort(null)).toEqual(null);
24+
});
25+
26+
test('can sort undefined correctly', () => {
27+
expect(deepSort(undefined)).toEqual(undefined);
28+
});
29+
30+
test('can sort object with nested keys correctly', () => {
31+
// Prepare
32+
const input = {
33+
name: 'John',
34+
age: 30,
35+
city: 'New York',
36+
address: {
37+
street: '5th Avenue',
38+
number: 123,
39+
},
40+
};
41+
42+
// Act
43+
const result = deepSort(input);
44+
45+
// Assess
46+
expect(JSON.stringify(result)).toEqual(
47+
JSON.stringify({
48+
address: {
49+
number: 123,
50+
street: '5th Avenue',
51+
},
52+
age: 30,
53+
city: 'New York',
54+
name: 'John',
55+
})
56+
);
57+
});
58+
59+
test('can sort deeply nested structures', () => {
60+
// Prepare
61+
const input = {
62+
z: [{ b: { d: 4, c: 3 }, a: { f: 6, e: 5 } }],
63+
a: { c: 3, b: 2, a: 1 },
64+
};
65+
66+
// Act
67+
const result = deepSort(input);
68+
69+
//Assess
70+
expect(JSON.stringify(result)).toEqual(
71+
JSON.stringify({
72+
a: { a: 1, b: 2, c: 3 },
73+
z: [{ a: { e: 5, f: 6 }, b: { c: 3, d: 4 } }],
74+
})
75+
);
76+
});
77+
78+
test('can sort JSON array with objects containing words as keys and nested objects/arrays correctly', () => {
79+
// Prepare
80+
const input = [
81+
{
82+
transactions: [
83+
50,
84+
40,
85+
{ field: 'a', category: 'x', purpose: 's' },
86+
[
87+
{
88+
zone: 'c',
89+
warehouse: 'd',
90+
attributes: { region: 'a', quality: 'x', batch: 's' },
91+
},
92+
],
93+
],
94+
totalAmount: 30,
95+
customerName: 'John',
96+
location: 'New York',
97+
transactionType: 'a',
98+
},
99+
{
100+
customerName: 'John',
101+
location: 'New York',
102+
transactionDetails: [
103+
{ field: 'a', category: 'x', purpose: 's' },
104+
null,
105+
50,
106+
[{ zone: 'c', warehouse: 'd', attributes: 't' }],
107+
40,
108+
],
109+
amount: 30,
110+
},
111+
];
112+
113+
// Act
114+
const result = deepSort(input);
115+
116+
// Assess
117+
expect(JSON.stringify(result)).toEqual(
118+
JSON.stringify([
119+
{
120+
customerName: 'John',
121+
location: 'New York',
122+
totalAmount: 30,
123+
transactions: [
124+
50,
125+
40,
126+
{ category: 'x', field: 'a', purpose: 's' },
127+
[
128+
{
129+
attributes: { batch: 's', quality: 'x', region: 'a' },
130+
warehouse: 'd',
131+
zone: 'c',
132+
},
133+
],
134+
],
135+
transactionType: 'a',
136+
},
137+
{
138+
amount: 30,
139+
customerName: 'John',
140+
location: 'New York',
141+
transactionDetails: [
142+
{ category: 'x', field: 'a', purpose: 's' },
143+
null,
144+
50,
145+
[{ attributes: 't', warehouse: 'd', zone: 'c' }],
146+
40,
147+
],
148+
},
149+
])
150+
);
151+
});
152+
153+
test('handles empty objects and arrays correctly', () => {
154+
expect(deepSort({})).toEqual({});
155+
expect(deepSort([])).toEqual([]);
156+
});
157+
});

0 commit comments

Comments
 (0)