Created
February 7, 2022 17:43
-
-
Save andrewmd5/e1d89d43d42318d4bc1b6d9a7d59019a to your computer and use it in GitHub Desktop.
Convert JSON to and from Bebop
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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