Skip to content
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

feat(NOTIFY-1128): add batch PUT endpoint support #4723

Merged
merged 3 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createSHA256Hash } from '../../shared/encryption';
import type {
UserStoragePathWithFeatureAndKey,
UserStoragePathWithFeatureOnly,
UserStoragePathWithKeyOnly,
} from '../../shared/storage-schema';
import type { NativeScrypt } from '../../shared/types/encryption';
import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests';
Expand All @@ -42,6 +43,7 @@ import {
} from './accounts/user-storage';
import { startNetworkSyncing } from './network-syncing/controller-integration';
import {
batchUpsertUserStorage,
getUserStorage,
getUserStorageAllFeatureEntries,
upsertUserStorage,
Expand Down Expand Up @@ -635,6 +637,32 @@ export default class UserStorageController extends BaseController<
});
}

/**
* Allows storage of multiple user data entries for one specific feature. Data stored must be string formatted.
* Developers can extend the entry path through the `schema.ts` file.
*
* @param path - string in the form of `${feature}` that matches schema
* @param values - data to store, in the form of an array of `[entryKey, entryValue]` pairs
* @returns nothing. NOTE that an error is thrown if fails to store data.
*/

public async performBatchSetStorage(
path: UserStoragePathWithFeatureOnly,
values: [UserStoragePathWithKeyOnly, string][],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool tuple!

Was wondering if this could be an obj, but works fine as is.

): Promise<void> {
this.#assertProfileSyncingEnabled();

const { bearerToken, storageKey } =
await this.#getStorageKeyAndBearerToken();

await batchUpsertUserStorage(values, {
path,
bearerToken,
storageKey,
nativeScryptCrypto: this.#nativeScryptCrypto,
});
}

/**
* Retrieves the storage key, for internal use only!
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,13 @@ export const getMockUserStoragePutResponse = (
response: null,
} satisfies MockResponse;
};

export const getMockUserStorageBatchPutResponse = (
path: UserStoragePathWithFeatureOnly = 'notifications',
) => {
return {
url: getMockUserStorageEndpoint(path),
requestMethod: 'PUT',
response: null,
} satisfies MockResponse;
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getMockUserStorageGetResponse,
getMockUserStoragePutResponse,
getMockUserStorageAllFeatureEntriesResponse,
getMockUserStorageBatchPutResponse,
} from './mockResponses';

type MockReply = {
Expand Down Expand Up @@ -52,10 +53,27 @@ export const mockEndpointGetUserStorage = async (
export const mockEndpointUpsertUserStorage = (
path: UserStoragePathWithFeatureAndKey = 'notifications.notification_settings',
mockReply?: Pick<MockReply, 'status'>,
expectCallback?: (requestBody: nock.Body) => Promise<void>,
) => {
const mockResponse = getMockUserStoragePutResponse(path);
const mockEndpoint = nock(mockResponse.url)
.put('')
.reply(mockReply?.status ?? 204);
.reply(mockReply?.status ?? 204, async (_, requestBody) => {
await expectCallback?.(requestBody);
});
return mockEndpoint;
};

export const mockEndpointBatchUpsertUserStorage = (
path: UserStoragePathWithFeatureOnly = 'notifications',
mockReply?: Pick<MockReply, 'status'>,
callback?: (uri: string, requestBody: nock.Body) => Promise<void>,
) => {
const mockResponse = getMockUserStorageBatchPutResponse(path);
const mockEndpoint = nock(mockResponse.url)
.put('')
.reply(mockReply?.status ?? 204, async (uri, requestBody) => {
return await callback?.(uri, requestBody);
});
return mockEndpoint;
};
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { UserStoragePathWithKeyOnly } from 'src/shared/storage-schema';

import encryption, { createSHA256Hash } from '../../shared/encryption';
import {
mockEndpointGetUserStorage,
mockEndpointUpsertUserStorage,
mockEndpointGetUserStorageAllFeatureEntries,
mockEndpointBatchUpsertUserStorage,
} from './__fixtures__/mockServices';
import {
MOCK_ENCRYPTED_STORAGE_DATA,
MOCK_STORAGE_DATA,
MOCK_STORAGE_KEY,
} from './__fixtures__/mockStorage';
import type { GetUserStorageResponse } from './services';
import {
batchUpsertUserStorage,
getUserStorage,
getUserStorageAllFeatureEntries,
upsertUserStorage,
Expand Down Expand Up @@ -132,16 +136,31 @@ describe('user-storage/services.ts - getUserStorageAllFeatureEntries() tests', (

describe('user-storage/services.ts - upsertUserStorage() tests', () => {
const actCallUpsertUserStorage = async () => {
const encryptedData = await MOCK_ENCRYPTED_STORAGE_DATA();
return await upsertUserStorage(encryptedData, {
return await upsertUserStorage(MOCK_STORAGE_DATA, {
bearerToken: 'MOCK_BEARER_TOKEN',
path: 'notifications.notification_settings',
storageKey: MOCK_STORAGE_KEY,
});
};

it('invokes upsert endpoint with no errors', async () => {
const mockUpsertUserStorage = mockEndpointUpsertUserStorage();
const mockUpsertUserStorage = mockEndpointUpsertUserStorage(
'notifications.notification_settings',
undefined,
async (requestBody) => {
if (typeof requestBody === 'string') {
return;
}

const decryptedBody = await encryption.decryptString(
requestBody.data,
MOCK_STORAGE_KEY,
);

expect(decryptedBody).toBe(MOCK_STORAGE_DATA);
},
);

await actCallUpsertUserStorage();

expect(mockUpsertUserStorage.isDone()).toBe(true);
Expand All @@ -159,3 +178,66 @@ describe('user-storage/services.ts - upsertUserStorage() tests', () => {
mockUpsertUserStorage.done();
});
});

describe('user-storage/services.ts - batchUpsertUserStorage() tests', () => {
const dataToStore: [UserStoragePathWithKeyOnly, string][] = [
['0x123', MOCK_STORAGE_DATA],
['0x456', MOCK_STORAGE_DATA],
];

const actCallBatchUpsertUserStorage = async () => {
return await batchUpsertUserStorage(dataToStore, {
bearerToken: 'MOCK_BEARER_TOKEN',
path: 'accounts',
storageKey: MOCK_STORAGE_KEY,
});
};

it('invokes upsert endpoint with no errors', async () => {
const mockUpsertUserStorage = mockEndpointBatchUpsertUserStorage(
'accounts',
undefined,
async (_uri, requestBody) => {
if (typeof requestBody === 'string') {
return;
}

const decryptedBody = await Promise.all(
Object.entries<string>(requestBody.data).map(
async ([entryKey, entryValue]) => {
return [
entryKey,
await encryption.decryptString(entryValue, MOCK_STORAGE_KEY),
];
},
),
);

const expectedBody = dataToStore.map(([entryKey, entryValue]) => [
createSHA256Hash(String(entryKey) + MOCK_STORAGE_KEY),
entryValue,
]);

expect(decryptedBody).toStrictEqual(expectedBody);
},
);

await actCallBatchUpsertUserStorage();

expect(mockUpsertUserStorage.isDone()).toBe(true);
});

it('throws error if unable to upsert user storage', async () => {
const mockUpsertUserStorage = mockEndpointBatchUpsertUserStorage(
'accounts',
{
status: 500,
},
);

await expect(actCallBatchUpsertUserStorage()).rejects.toThrow(
expect.any(Error),
);
mockUpsertUserStorage.done();
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import log from 'loglevel';

import encryption from '../../shared/encryption';
import encryption, { createSHA256Hash } from '../../shared/encryption';
import { Env, getEnvUrls } from '../../shared/env';
import type {
UserStoragePathWithFeatureAndKey,
UserStoragePathWithFeatureOnly,
UserStoragePathWithKeyOnly,
} from '../../shared/storage-schema';
import { createEntryPath } from '../../shared/storage-schema';
import type { NativeScrypt } from '../../shared/types/encryption';
Expand Down Expand Up @@ -45,6 +46,8 @@ export type UserStorageAllFeatureEntriesOptions = UserStorageBaseOptions & {
path: UserStoragePathWithFeatureOnly;
};

export type UserStorageBatchUpsertOptions = UserStorageAllFeatureEntriesOptions;

/**
* User Storage Service - Get Storage Entry.
*
Expand Down Expand Up @@ -187,3 +190,47 @@ export async function upsertUserStorage(
throw new Error('user-storage - unable to upsert data');
}
}

/**
* User Storage Service - Set multiple storage entries for one specific feature.
* You cannot use this method to set multiple features at once.
*
* @param data - data to store, in the form of an array of [entryKey, entryValue] pairs
* @param opts - storage options
*/
export async function batchUpsertUserStorage(
data: [UserStoragePathWithKeyOnly, string][],
opts: UserStorageBatchUpsertOptions,
): Promise<void> {
const { bearerToken, path, storageKey, nativeScryptCrypto } = opts;

const encryptedData = await Promise.all(
data.map(async (d) => {
return [
createSHA256Hash(d[0] + storageKey),
await encryption.encryptString(
d[1],
opts.storageKey,
nativeScryptCrypto,
),
];
}),
);

const url = new URL(`${USER_STORAGE_ENDPOINT}/${path}`);

const formattedData = Object.fromEntries(encryptedData);

const res = await fetch(url.toString(), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({ data: formattedData }),
});

if (!res.ok) {
throw new Error('user-storage - unable to batch upsert data');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ export const handleMockUserStorageGetAllFeatureEntries = async (
return mockEndpoint;
};

export const handleMockUserStoragePut = (mockReply?: MockReply) => {
export const handleMockUserStoragePut = (
mockReply?: MockReply,
callback?: (uri: string, requestBody: nock.Body) => Promise<void>,
) => {
const reply = mockReply ?? { status: 204 };
const mockEndpoint = nock(MOCK_STORAGE_URL)
.persist()
.put(/.*/u)
.reply(reply.status);
.reply(reply.status, async (uri, requestBody) => {
return await callback?.(uri, requestBody);
});

return mockEndpoint;
};
Loading
Loading