Skip to content

Commit

Permalink
fix(idempotency): deep sort payload during hashing (#2570)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrea Amorosi <dreamorosi@gmail.com>
  • Loading branch information
arnabrahman and dreamorosi authored Jun 3, 2024
1 parent f958d52 commit 6765f35
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 2 deletions.
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([]);
});
});

0 comments on commit 6765f35

Please sign in to comment.