From 7775e828514e58f5055c011f3025dd59e4e25c21 Mon Sep 17 00:00:00 2001 From: John Knox Date: Mon, 7 Oct 2019 08:49:05 -0700 Subject: [PATCH] Add JsonFileStorage for redux-persist Summary: This will be used for the settings file. It results in normal JSON, as opposed to json where the value of every key is an escaped string. Reviewed By: passy Differential Revision: D17712688 fbshipit-source-id: d37ed93707c7352719fa72a05bf51953611f52c0 --- .../__tests__/data/settings-v1-valid.json | 1 + src/utils/__tests__/jsonFileStorage.node.js | 37 ++++++++ src/utils/jsonFileReduxPersistStorage.tsx | 89 +++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/utils/__tests__/data/settings-v1-valid.json create mode 100644 src/utils/__tests__/jsonFileStorage.node.js create mode 100644 src/utils/jsonFileReduxPersistStorage.tsx diff --git a/src/utils/__tests__/data/settings-v1-valid.json b/src/utils/__tests__/data/settings-v1-valid.json new file mode 100644 index 000000000..b2b970965 --- /dev/null +++ b/src/utils/__tests__/data/settings-v1-valid.json @@ -0,0 +1 @@ +{"androidHome":"/opt/android_sdk","something":{"else":4},"_persist":{"version":-1,"rehydrated":true}} diff --git a/src/utils/__tests__/jsonFileStorage.node.js b/src/utils/__tests__/jsonFileStorage.node.js new file mode 100644 index 000000000..45e5999ab --- /dev/null +++ b/src/utils/__tests__/jsonFileStorage.node.js @@ -0,0 +1,37 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import JsonFileStorage from '../jsonFileReduxPersistStorage.tsx'; +import fs from 'fs'; + +const validSerializedData = fs + .readFileSync('src/utils/__tests__/data/settings-v1-valid.json') + .toString() + .trim(); + +const validDeserializedData = + '{"androidHome":"\\"/opt/android_sdk\\"","something":"{\\"else\\":4}","_persist":"{\\"version\\":-1,\\"rehydrated\\":true}"}'; + +const storage = new JsonFileStorage( + 'src/utils/__tests__/data/settings-v1-valid.json', +); + +test('A valid settings file gets parsed correctly', () => { + return storage + .getItem('anykey') + .then(result => expect(result).toEqual(validDeserializedData)); +}); + +test('deserialize works as expected', () => { + const deserialized = storage.deserializeValue(validSerializedData); + expect(deserialized).toEqual(validDeserializedData); +}); + +test('serialize works as expected', () => { + const serialized = storage.serializeValue(validDeserializedData); + expect(serialized).toEqual(validSerializedData); +}); diff --git a/src/utils/jsonFileReduxPersistStorage.tsx b/src/utils/jsonFileReduxPersistStorage.tsx new file mode 100644 index 000000000..8459f0f7d --- /dev/null +++ b/src/utils/jsonFileReduxPersistStorage.tsx @@ -0,0 +1,89 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import {promises} from 'fs'; + +/** + * Redux-persist storage engine for storing state in a human readable JSON file. + * + * Differs from the usual engines in two ways: + * * The key is ignored. This storage will only hold one key, so each setItem() call will overwrite the previous one. + * * Stored files are "human readable". Redux-persist calls storage engines with preserialized values that contain escaped strings inside json. + * This engine re-serializes them by parsing the inner strings to store them as top-level json. + * Transforms haven't been used because they operate before serialization, so all serialized values would still end up as strings. + */ +export default class JsonFileStorage { + filepath: string; + constructor(filepath: string) { + this.filepath = filepath; + } + + private parseFile(): Promise { + return promises + .readFile(this.filepath) + .then(buffer => buffer.toString()) + .then(this.deserializeValue) + .catch(e => { + console.error( + `Failed to read settings file: "${ + this.filepath + }". ${e}. Replacing file with default settings.`, + ); + return promises + .writeFile(this.filepath, JSON.stringify({})) + .then(() => ({})); + }); + } + + getItem(_key: string, callback?: (_: any) => any): Promise { + const promise = this.parseFile(); + callback && promise.then(callback); + return promise; + } + + // Sets a new value and returns the value that was PREVIOUSLY set. + // This mirrors the behaviour of the localForage storage engine. + // Not thread-safe. + setItem(_key: string, value: any, callback?: (_: any) => any): Promise { + const originalValue = this.parseFile(); + const writePromise = originalValue.then(_ => + promises.writeFile(this.filepath, this.serializeValue(value)), + ); + + return Promise.all([originalValue, writePromise]).then(([o, _]) => { + callback && callback(o); + return o; + }); + } + + removeItem(_key: string, callback?: () => any): Promise { + return promises + .writeFile(this.filepath, JSON.stringify({})) + .then(_ => callback && callback()) + .then(() => {}); + } + + serializeValue(value: string): string { + const reconstructedObject = Object.entries(JSON.parse(value)) + .map(([k, v]: [string, unknown]) => [k, JSON.parse(v as string)]) + .reduce((acc: {[key: string]: any}, cv) => { + acc[cv[0]] = cv[1]; + return acc; + }, {}); + return JSON.stringify(reconstructedObject); + } + + deserializeValue(value: string): string { + const reconstructedObject = Object.entries(JSON.parse(value)) + .map(([k, v]: [string, unknown]) => [k, JSON.stringify(v)]) + .reduce((acc: {[key: string]: string}, cv) => { + acc[cv[0]] = cv[1]; + return acc; + }, {}); + return JSON.stringify(reconstructedObject); + } +}