Skip to content

fix(idempotency): deep sort payload during hashing #2570

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
46 changes: 46 additions & 0 deletions packages/idempotency/src/deepSort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getType } from '@aws-lambda-powertools/commons';
import {
JSONArray,
JSONObject,
JSONValue,
} from '@aws-lambda-powertools/commons/types';

/**
* Sorts the keys of a provided object in a case-insensitive manner.
*
* This function takes an object as input, sorts its keys alphabetically without
* considering case sensitivity and recursively sorts any nested objects or arrays.
*
* @param {JSONObject} object - The JSON object to be sorted.
* @returns {JSONObject} - A new JSON object with all keys sorted alphabetically in a case-insensitive manner.
*/
const sortObject = (object: JSONObject): JSONObject =>
Object.keys(object)
.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1))
.reduce((acc, key) => {
acc[key] = deepSort(object[key]);

return acc;
}, {} as JSONObject);

/**
* Recursively sorts the keys of an object or elements of an array.
*
* This function sorts the keys of any JSON in a case-insensitive manner and recursively applies the same sorting to
* nested objects and arrays. Primitives (strings, numbers, booleans, null) are returned unchanged.
*
* @param {JSONValue} data - The input data to be sorted, which can be an object, array or primitive value.
* @returns {JSONValue} - The sorted data, with all object's keys sorted alphabetically in a case-insensitive manner.
*/
const deepSort = (data: JSONValue): JSONValue => {
const type = getType(data);
if (type === 'object') {
return sortObject(data as JSONObject);
} else if (type === 'array') {
return (data as JSONArray).map(deepSort);
}

return data;
};

export { deepSort };
5 changes: 3 additions & 2 deletions packages/idempotency/src/persistence/BasePersistenceLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../errors.js';
import { LRUCache } from './LRUCache.js';
import type { JSONValue } from '@aws-lambda-powertools/commons/types';
import { deepSort } from '../deepSort.js';

/**
* Base class for all persistence layers. This class provides the basic functionality for
Expand Down Expand Up @@ -301,7 +302,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
}

return `${this.idempotencyKeyPrefix}#${this.generateHash(
JSON.stringify(data)
JSON.stringify(deepSort(data))
)}`;
}

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

return this.generateHash(JSON.stringify(data));
return this.generateHash(JSON.stringify(deepSort(data)));
} else {
return '';
}
Expand Down
157 changes: 157 additions & 0 deletions packages/idempotency/tests/unit/deepSort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Test deepSort Function
*
* @group unit/idempotency/deepSort
*/
import { deepSort } from '../../src/deepSort';

describe('Function: deepSort', () => {
test('can sort string correctly', () => {
expect(deepSort('test')).toEqual('test');
});

test('can sort number correctly', () => {
expect(deepSort(5)).toEqual(5);
});

test('can sort boolean correctly', () => {
expect(deepSort(true)).toEqual(true);
expect(deepSort(false)).toEqual(false);
});

test('can sort null correctly', () => {
expect(deepSort(null)).toEqual(null);
});

test('can sort undefined correctly', () => {
expect(deepSort(undefined)).toEqual(undefined);
});

test('can sort object with nested keys correctly', () => {
// Prepare
const input = {
name: 'John',
age: 30,
city: 'New York',
address: {
street: '5th Avenue',
number: 123,
},
};

// Act
const result = deepSort(input);

// Assess
expect(JSON.stringify(result)).toEqual(
JSON.stringify({
address: {
number: 123,
street: '5th Avenue',
},
age: 30,
city: 'New York',
name: 'John',
})
);
});

test('can sort deeply nested structures', () => {
// Prepare
const input = {
z: [{ b: { d: 4, c: 3 }, a: { f: 6, e: 5 } }],
a: { c: 3, b: 2, a: 1 },
};

// Act
const result = deepSort(input);

//Assess
expect(JSON.stringify(result)).toEqual(
JSON.stringify({
a: { a: 1, b: 2, c: 3 },
z: [{ a: { e: 5, f: 6 }, b: { c: 3, d: 4 } }],
})
);
});

test('can sort JSON array with objects containing words as keys and nested objects/arrays correctly', () => {
// Prepare
const input = [
{
transactions: [
50,
40,
{ field: 'a', category: 'x', purpose: 's' },
[
{
zone: 'c',
warehouse: 'd',
attributes: { region: 'a', quality: 'x', batch: 's' },
},
],
],
totalAmount: 30,
customerName: 'John',
location: 'New York',
transactionType: 'a',
},
{
customerName: 'John',
location: 'New York',
transactionDetails: [
{ field: 'a', category: 'x', purpose: 's' },
null,
50,
[{ zone: 'c', warehouse: 'd', attributes: 't' }],
40,
],
amount: 30,
},
];

// Act
const result = deepSort(input);

// Assess
expect(JSON.stringify(result)).toEqual(
JSON.stringify([
{
customerName: 'John',
location: 'New York',
totalAmount: 30,
transactions: [
50,
40,
{ category: 'x', field: 'a', purpose: 's' },
[
{
attributes: { batch: 's', quality: 'x', region: 'a' },
warehouse: 'd',
zone: 'c',
},
],
],
transactionType: 'a',
},
{
amount: 30,
customerName: 'John',
location: 'New York',
transactionDetails: [
{ category: 'x', field: 'a', purpose: 's' },
null,
50,
[{ attributes: 't', warehouse: 'd', zone: 'c' }],
40,
],
},
])
);
});

test('handles empty objects and arrays correctly', () => {
expect(deepSort({})).toEqual({});
expect(deepSort([])).toEqual([]);
});
});