Skip to content

Instantly share code, notes, and snippets.

@andrewmd5
Created February 7, 2022 17:43
Show Gist options
  • Save andrewmd5/e1d89d43d42318d4bc1b6d9a7d59019a to your computer and use it in GitHub Desktop.
Save andrewmd5/e1d89d43d42318d4bc1b6d9a7d59019a to your computer and use it in GitHub Desktop.
Convert JSON to and from Bebop
#!/usr/bin/env node
import * as fs from "fs";
import * as ts from "typescript";
import { checkSchema } from "bebop-tools";
import path = require("path");
import child_process = require("child_process");
let usage = [
"",
"Usage: bebopm [OPTIONS]",
"",
"Options:",
"",
" --help Print this message.",
" --schema [PATH] The schema file to use.",
" --output [PATH] the output file name.",
" --to-json [PATH] Convert a binary file to JSON.",
" --from-json [PATH] Convert a JSON file to binary.",
" --root-type [NAME] Set the root type for JSON.",
"",
"Examples:",
"",
" bebopm --schema requests.bop --binary buffer.bin --root-type UserFeedbackSubmission --from-json mock.json",
" bebopm --schema requests.bop --root-type UserFeedbackSubmission --to-json buffer.bin",
"",
].join("\n");
export async function main(args: string[]): Promise<number> {
let flags: { [flag: string]: string | undefined } = {
"--schema": undefined,
"--output": undefined,
"--to-json": undefined,
"--from-json": undefined,
"--root-type": undefined,
};
// Parse flags
for (let i = 0; i < args.length; i++) {
let arg = args[i];
if (arg === "-h" || arg === "--help" || arg[0] !== "-") {
console.log(usage);
return 1;
} else if (arg in flags) {
if (i + 1 === args.length) {
throw new Error(
"Missing value for " + JSON.stringify(arg) +
' (use "--help" for usage)',
);
}
flags[arg] = args[++i];
} else {
throw new Error(
"Unknown flag " + JSON.stringify(arg) + ' (use "--help" for usage)',
);
}
}
// Must have a schema
if (flags["--schema"] === undefined) {
console.log(usage);
return 1;
}
if (flags["--to-json"] !== undefined && flags["--from-json"] !== undefined) {
console.log(usage);
return 1;
}
if (flags["--from-json"] !== undefined && flags["--output"] === undefined) {
console.log(usage);
return 1;
}
if (flags["--to-json"] !== undefined && flags["--output"] === undefined) {
console.log(usage);
return 1;
}
// Must have a root type
if (flags["--root-type"] === undefined) {
console.log(usage);
return 1;
}
// Try loading the schema
const buffer = fs.readFileSync(flags["--schema"]);
const isText = Array.prototype.indexOf.call(buffer, 0) === -1;
if (!isText) {
throw new Error("A binary file was provided as the source schema");
}
const content = buffer.toString();
const checkResult = await checkSchema(content);
if (checkResult.error) {
console.log("are we here");
var errorMessage = `specified schema ${flags["--schema"]} is not valid:\n`;
if (checkResult.issues) {
checkResult.issues.forEach((issue) => {
errorMessage += flags["--schema"] + ":" + issue.startLine + ":" +
issue.startColumn + ": error: " + issue.description;
errorMessage += "\n" + content.split("\n")[issue.endLine - 1] + "\n" +
new Array(issue.endColumn).join(" ") + "^";
});
}
throw new Error(errorMessage);
}
// Validate the root type
const schemaPath = flags["--schema"];
const rootTypeName = flags["--root-type"];
const generatedOutput = `./worker/generated/test/${
path.parse(schemaPath).name
}.ts`;
const interfaceOutput = `./worker/generated/test/${
path.parse(schemaPath).name
}-ti.ts`;
await generateCode(schemaPath, generatedOutput);
const transpiled = tsCompile(fs.readFileSync(generatedOutput, "utf8"));
await buildInterfaces(generatedOutput);
const interfaces = tsCompile(fs.readFileSync(interfaceOutput, "utf8"));
const outputPath = flags["--output"];
// Scoping function to fake the effect of module scope
(function () {
var exportIntefaces = evalGeneratedCode(interfaces);
exportIntefaces();
var exportCode = evalGeneratedCode(transpiled);
exportCode();
const rootType = exports[`${rootTypeName}`];
const rootInterface = exports[`I${rootTypeName}`];
const isTypeExported = rootType &&
typeof rootType.encode === "function" &&
typeof rootType.decode === "function" && rootInterface;
if (!isTypeExported) {
throw new Error(
`Unable to load root type '${rootTypeName}' from ${flags["--schema"]}`,
);
}
if (!rootInterface.props) {
throw new Error(
`Unable to load interface properties for root type '${rootTypeName}'`,
);
}
if (flags["--from-json"]) {
const jsonPath = flags["--from-json"];
const parsed = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
if (!parsed || !(parsed instanceof Object)) {
throw new Error(`JSON parsing of ${jsonPath} failed`);
}
rootInterface.props.forEach((prop) => {
if (`${prop.name}` in parsed) {
parsed[`${prop.name}`] = setValueType(
prop.name,
parsed[`${prop.name}`],
prop.ttype,
prop.isOpt,
);
}
});
const buffer = rootType.encode(parsed);
fs.writeFileSync(outputPath, buffer);
console.log(
`Encoded data ${buffer.length} bytes from ${jsonPath} as ${rootTypeName} to ${outputPath}`,
);
return 0;
}
if (flags["--to-json"]) {
const binaryPath = flags["--to-json"];
const buffer = fs.readFileSync(binaryPath);
const decoded = rootType.decode(buffer);
fs.writeFileSync(
outputPath,
JSON.stringify(
decoded,
(key, value) => typeof value === "bigint" ? value.toString() : value, // return everything else unchanged
),
);
console.log(
`Decoded ${buffer.length} bytes from ${binaryPath} as ${rootTypeName} to ${outputPath}`,
);
return 0;
}
function setValueType(
propertyName: string,
value: any,
type: { name: string; _failMsg: string },
isOpt: boolean,
) {
if (!propertyName) {
throw new Error("No property name specified");
}
if (!type) {
throw new Error("No type specified");
}
const failMessage = `${propertyName} ${type._failMsg}`;
if (isOpt === true && (value === undefined || value === null)) {
return undefined;
}
if (!isOpt && (value === undefined || value === null)) {
throw new Error(`required property is undeinfed: ${failMessage}`);
}
if (type.name === "string" && typeof value !== "string") {
throw new Error(failMessage);
} else if (type.name === "boolean" && typeof value !== "boolean") {
throw new Error(failMessage);
} else if (type.name === "number" && typeof value !== "number") {
throw new Error(failMessage);
}
switch (type.name) {
case "bigint":
return BigInt(value);
case "Date":
return new Date(value);
case "string":
case "boolean":
case "number":
return value;
default:
break;
}
if (value instanceof Object) {
// its a union!
if (
type.name.startsWith("I") &&
exports[`${type.name}`].ttypes instanceof Array
) {
if (!(value instanceof Object)) {
throw new Error(failMessage);
}
const valueKeys = new Set(Object.keys(value));
for (const unionType of exports[`${type.name}`].ttypes) {
const memberName = unionType?.props?.find((p) =>
p.name === "value"
);
const discriminator = unionType?.props?.find((p) =>
p.name === "discriminator"
);
if (!memberName || !discriminator) {
continue;
}
let isMatch = eqSet(
valueKeys,
exports[`${memberName.ttype.name}`].propSet,
);
if (!isMatch) {
console.log(`no match for ${memberName.ttype.name}`);
continue;
}
const clone = Object.assign({}, value);
value = {};
value.discriminator = discriminator.ttype.value;
value.value = clone;
return setValueType(
propertyName,
value,
memberName.ttype,
memberName.isOpt,
);
} // a struct or message
} else if (type.name.startsWith("I")) {
const interfaceProps = exports[`${type.name}`].props;
if (!interfaceProps) {
throw new Error(`Unable to get interface for ${type.name}`);
}
const localValue = Object.keys(value).includes("discriminator") &&
Object.keys(value).includes("value")
? value.value
: value;
for (const [memberName, currentValue] of Object.entries(localValue)) {
const memberType = interfaceProps.find((p) =>
p.name === memberName
);
localValue[memberName] = setValueType(
memberName,
currentValue,
memberType.ttype,
memberType.isOpt,
);
}
return value;
}
} else {
// is this an enum?
const enumType = exports[`${type.name}`];
if (
typeof value === "string" && Object.keys(enumType).includes(value)
) {
return enumType[value];
} else if (
typeof value === "number" &&
Object.keys(enumType).includes(value.toString())
) {
return value;
}
throw new Error(`Enum '${type.name}' does not contain ${value}`);
}
throw new Error(`'${type.name}' is not handled ${value}`);
}
function eqSet(as, bs) {
if (as.size !== bs.size) return false;
for (var a of as) if (!bs.has(a)) return false;
return true;
}
// Isolate the impact of eval
function evalGeneratedCode(code: string) {
return eval("(function() { " + code + "})");
}
})();
return 0;
}
if (require.main === module) {
(async () => {
process.exit(await main(process.argv.slice(2)));
})();
}
function tsCompile(
source: string,
options: ts.CompilerOptions = null,
): string {
// Default options -- you could also perform a merge, or use the project tsconfig.json
if (null === options) {
options = { module: ts.ModuleKind.CommonJS };
}
return ts.transpile(source, options);
}
async function generateCode(
schema: string,
output: string,
): Promise<void> {
return new Promise((resolve, reject) => {
child_process.exec(
`bebopc --files "${schema}" --log-format JSON --ts ${output}`,
(error, stdout, stderr) => {
if (stderr.trim().length > 0) {
resolve();
}
resolve();
},
);
});
}
async function buildInterfaces(path: string): Promise<void> {
return new Promise<void>(async (resolve, reject) => {
child_process.exec(
`./node_modules/.bin/ts-interface-builder ${path}`,
(error, stdout, stderr) => {
console.log(stdout);
console.log(stderr);
resolve();
},
);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment