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:
Robin van Boven 2020-02-08 08:39:06 +01:00 committed by GitHub
parent b1a6865985
commit 1a58745d3b
3 changed files with 305 additions and 1 deletions

60
src/backend/compatIO.js Normal file
View File

@ -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}`
);
}
};
}

View File

@ -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);
});
});
});

View File

@ -191,7 +191,7 @@ type IndexedEdgeJSON = {|
+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
// it is referenced by a dangling edge.
+sortedNodeAddresses: AddressJSON[],