Create compatIO utility functions (#1637)
Currently similar code to read/write Compatible JSON files is copy pasted across the code. This takes some common practices and provides a generic utility for it. Correct Flow type usage can't be detected if the JSON type is opaque though. GraphJSON is an example of this, so removed the opaque for a smoke test.
This commit is contained in:
parent
b1a6865985
commit
1a58745d3b
|
@ -0,0 +1,60 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import fs from "fs-extra";
|
||||||
|
import stringify from "json-stable-stringify";
|
||||||
|
import {type Compatible} from "../util/compat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility functions, which allow us to read/write a Compatible of any
|
||||||
|
* type to a JSON file. They should automatically detect the Flow types
|
||||||
|
* and produce errors when misused.
|
||||||
|
*
|
||||||
|
* Note, the Compatible<T> type used cannot be opaque.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Compatible JSON file writer.
|
||||||
|
* The writer's Flow type for `data` will automatically match the provided
|
||||||
|
* `toJSON`'s argument type.
|
||||||
|
* Optionally takes a `typeName` to improve error messages.
|
||||||
|
*/
|
||||||
|
export function compatWriter<T, J>(
|
||||||
|
toJSON: (T) => Compatible<J>,
|
||||||
|
typeName?: string
|
||||||
|
): (path: string, data: T) => Promise<void> {
|
||||||
|
return async (path: string, data: T): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await fs.writeFile(path, stringify(toJSON(data)));
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not write ${typeName ? typeName + " " : ""}data:\n${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Compatible JSON file reader.
|
||||||
|
* The reader's Flow type for the return value will automatically match the
|
||||||
|
* provided `fromJSON`'s return type.
|
||||||
|
* Optionally takes a `typeName` to improve error messages.
|
||||||
|
*/
|
||||||
|
export function compatReader<T>(
|
||||||
|
fromJSON: (Compatible<any>) => T,
|
||||||
|
typeName?: string
|
||||||
|
): (path: string) => Promise<T> {
|
||||||
|
return async (path: string): Promise<T> => {
|
||||||
|
if (!(await fs.exists(path))) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find ${typeName ? typeName + " " : ""}file at: ${path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fromJSON(JSON.parse(await fs.readFile(path)));
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`Provided ${typeName ? typeName + " " : ""}file is invalid:\n${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,244 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
import tmp from "tmp";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs-extra";
|
||||||
|
import {type Compatible, toCompat, fromCompat} from "../util/compat";
|
||||||
|
import {createProject, projectToJSON, projectFromJSON} from "../core/project";
|
||||||
|
import {Graph} from "../core/graph";
|
||||||
|
import * as WeightedGraph from "../core/weightedGraph";
|
||||||
|
import {node as graphNode} from "../core/graphTestUtil";
|
||||||
|
import {compatWriter, compatReader} from "./compatIO";
|
||||||
|
|
||||||
|
type MockDataType = {+[string]: string};
|
||||||
|
|
||||||
|
const mockCompatInfo = {type: "mock", version: "1"};
|
||||||
|
|
||||||
|
function mockToJSON(x: MockDataType): Compatible<MockDataType> {
|
||||||
|
return toCompat(mockCompatInfo, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFromJSON(x: Compatible<any>): MockDataType {
|
||||||
|
return fromCompat(mockCompatInfo, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("src/backend/compatIO", () => {
|
||||||
|
describe("compatWriter", () => {
|
||||||
|
it("should work with example type", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const data: MockDataType = {foo: "bar"};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(mockToJSON);
|
||||||
|
await writer(filePath, data);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const actualData = await fs.readFile(filePath, "utf8");
|
||||||
|
expect(actualData).toEqual(
|
||||||
|
`[{"type":"mock","version":"1"},{"foo":"bar"}]`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle write errors", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = tmp.dirSync().name;
|
||||||
|
const data: MockDataType = {foo: "bar"};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(mockToJSON);
|
||||||
|
const p = writer(filePath, data);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow("Could not write data:\nError: EISDIR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept a typeName to improve errors", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = tmp.dirSync().name;
|
||||||
|
const data: MockDataType = {foo: "bar"};
|
||||||
|
const name = "TestName";
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(mockToJSON, name);
|
||||||
|
const p = writer(filePath, data);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow(
|
||||||
|
`Could not write ${name} data:\nError: EISDIR`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use stable json serialization", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const data: MockDataType = {bbb: "second", aaa: "first"};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(mockToJSON);
|
||||||
|
await writer(filePath, data);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const actualData = (await fs.readFile(filePath)).toString("utf-8");
|
||||||
|
expect(actualData).toEqual(
|
||||||
|
`[{"type":"mock","version":"1"},{"aaa":"first","bbb":"second"}]`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compatReader", () => {
|
||||||
|
it("should work with example type", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const fileContents = `[{"type":"mock","version":"1"},{"foo":"bar"}]`;
|
||||||
|
await fs.writeFile(filePath, fileContents);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const reader = compatReader(mockFromJSON);
|
||||||
|
const data = await reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(data).toEqual({foo: "bar"});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check the file exists", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
|
||||||
|
// When
|
||||||
|
const reader = compatReader(mockFromJSON);
|
||||||
|
const p = reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow("Could not find file at:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check for invalid file content", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
await fs.writeFile(filePath, "-not valid JSON-");
|
||||||
|
|
||||||
|
// When
|
||||||
|
const reader = compatReader(mockFromJSON);
|
||||||
|
const p = reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow(
|
||||||
|
"Provided file is invalid:\nSyntaxError: Unexpected token"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check for invalid compat type", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const fileContents = `[{"type":"wrong-type","version":"1"},{"foo":"bar"}]`;
|
||||||
|
await fs.writeFile(filePath, fileContents);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const reader = compatReader(mockFromJSON);
|
||||||
|
const p = reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow(
|
||||||
|
"Provided file is invalid:\nError: Expected type to be mock but got wrong-type"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check for invalid compat version", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const fileContents = `[{"type":"mock","version":"2"},{"foo":"bar"}]`;
|
||||||
|
await fs.writeFile(filePath, fileContents);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const reader = compatReader(mockFromJSON);
|
||||||
|
const p = reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow(
|
||||||
|
"Provided file is invalid:\nError: mock: tried to load unsupported version 2"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept a typeName to improve errors", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const name = "TestName";
|
||||||
|
|
||||||
|
// When
|
||||||
|
const reader = compatReader(mockFromJSON, name);
|
||||||
|
const p = reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(p).rejects.toThrow(`Could not find ${name} file at:`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compatReader + compatWriter", () => {
|
||||||
|
it("should work as a round-trip", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "mockData.json");
|
||||||
|
const data: MockDataType = {foo: "bar"};
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(mockToJSON);
|
||||||
|
const reader = compatReader(mockFromJSON);
|
||||||
|
await writer(filePath, data);
|
||||||
|
const actualData = await reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(actualData).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: this is a smoke test and can be safely removed if needed.
|
||||||
|
it("should work with Project Compatible type", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "project.json");
|
||||||
|
const project = createProject({id: "example-project"});
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(projectToJSON);
|
||||||
|
const reader = compatReader(projectFromJSON);
|
||||||
|
await writer(filePath, project);
|
||||||
|
const actual = await reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(actual).toEqual(project);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: this is a smoke test and can be safely removed if needed.
|
||||||
|
it("should work with Graph Compatible type", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "graph.json");
|
||||||
|
const graph = new Graph();
|
||||||
|
graph.addNode(graphNode("example-node"));
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter((g: Graph) => g.toJSON());
|
||||||
|
const reader = compatReader(Graph.fromJSON);
|
||||||
|
await writer(filePath, graph);
|
||||||
|
const actual = await reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(actual.equals(graph)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: this is a smoke test and can be safely removed if needed.
|
||||||
|
it("should work with WeightedGraph Compatible type", async () => {
|
||||||
|
// Given
|
||||||
|
const filePath = path.join(tmp.dirSync().name, "weightedGraph.json");
|
||||||
|
const wg = WeightedGraph.empty();
|
||||||
|
wg.graph.addNode(graphNode("example-node"));
|
||||||
|
|
||||||
|
// When
|
||||||
|
const writer = compatWriter(WeightedGraph.toJSON);
|
||||||
|
const reader = compatReader(WeightedGraph.fromJSON);
|
||||||
|
await writer(filePath, wg);
|
||||||
|
const actual = await reader(filePath);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(actual.graph.equals(wg.graph)).toBe(true);
|
||||||
|
expect(actual.weights).toEqual(wg.weights);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -191,7 +191,7 @@ type IndexedEdgeJSON = {|
|
||||||
+timestampMs: number,
|
+timestampMs: number,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
export opaque type GraphJSON = Compatible<{|
|
export type GraphJSON = Compatible<{|
|
||||||
// A node address can be present because it corresponds to a node, or because
|
// A node address can be present because it corresponds to a node, or because
|
||||||
// it is referenced by a dangling edge.
|
// it is referenced by a dangling edge.
|
||||||
+sortedNodeAddresses: AddressJSON[],
|
+sortedNodeAddresses: AddressJSON[],
|
||||||
|
|
Loading…
Reference in New Issue