From f5e6a6d4c2ea4e55f7e98e43cd220ea30feeeb42 Mon Sep 17 00:00:00 2001 From: Jeremy Clements Date: Tue, 14 Dec 2021 16:52:14 -0500 Subject: [PATCH] feat(utils): Wsdl to TS typings Signed-off-by: Jeremy Clements --- .gitignore | 1 + packages/comms/package.json | 16 +- packages/comms/utils/index.ts | 265 ++++++++++++++++++++++++++++++++++ packages/comms/utils/util.ts | 49 +++++++ 4 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 packages/comms/utils/index.ts create mode 100644 packages/comms/utils/util.ts diff --git a/.gitignore b/.gitignore index 2d2b130489..79831ebc2e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ types/ types-3.4/ packages/**/package-lock.json packages/common/font-awesome +packages/comms/temp packages/ddl-shim/src/ddlSchema.* packages/ddl-shim/src/ddl2Schema.* tests/**/package-lock.json diff --git a/packages/comms/package.json b/packages/comms/package.json index 4394e14d0d..92a944231d 100644 --- a/packages/comms/package.json +++ b/packages/comms/package.json @@ -22,6 +22,7 @@ "compile-es6-watch": "npm run compile-es6 -- -w", "compile-umd": "tsc --module umd --outDir ./lib-umd", "compile-umd-watch": "npm run compile-umd -- -w", + "compile-util": "tsc --esModuleInterop --target es2019 --skipLibCheck --module commonjs --outDir ./lib-cjs ./utils/index.ts", "bundle": "rollup -c", "bundle-watch": "npm run bundle -- -w", "minimize-browser": "terser dist/index.js -c -m --source-map \"content='dist/index.js.map',url='index.min.js.map'\" -o dist/index.min.js", @@ -35,7 +36,19 @@ "lint-fix": "eslint --fix src/**/*.ts", "test": "./node_modules/.bin/mocha lib-umd/__tests__ --reporter spec", "docs": "typedoc --options tdoptions.json .", - "update": "npx npm-check-updates -u -t minor" + "update": "npx npm-check-updates -u -t minor", + "wsdl-access": "node ./lib-cjs/index.js --url=http://localhost:8010/ws_access?wsdl --outDir=./src/services/wsdl", + "wsdl-account": "node ./lib-cjs/index.js --url=http://localhost:8010/Ws_Account?wsdl --outDir=./src/services/wsdl", + "wsdl-codesign": "node ./lib-cjs/index.js --url=http://localhost:8010/ws_codesign?wsdl --outDir=./src/services/wsdl", + "wsdl-dfu": "node ./lib-cjs/index.js --url=http://localhost:8010/WsDfu?wsdl --outDir=./src/services/wsdl", + "wsdl-dfuxref": "node ./lib-cjs/index.js --url=http://localhost:8010/WsDFUXRef?wsdl --outDir=./src/services/wsdl", + "wsdl-logaccess": "node ./lib-cjs/index.js --url=http://localhost:8010/Ws_logaccess?wsdl --outDir=./src/services/wsdl", + "wsdl-machine": "node ./lib-cjs/index.js --url=http://localhost:8010/ws_machine?wsdl --outDir=./src/services/wsdl", + "wsdl-smc": "node ./lib-cjs/index.js --url=http://localhost:8010/WsSMC?wsdl --outDir=./src/services/wsdl", + "wsdl-store": "node ./lib-cjs/index.js --url=http://localhost:8010/WsStore?wsdl --outDir=./src/services/wsdl", + "wsdl-topology": "node ./lib-cjs/index.js --url=http://localhost:8010/WsTopology?wsdl --outDir=./src/services/wsdl", + "wsdl-workunits": "node ./lib-cjs/index.js --url=http://localhost:8010/WsWorkunits?wsdl --outDir=./src/services/wsdl", + "wsdl": "npm-run-all --serial compile-util --parallel wsdl-account wsdl-codesign wsdl-dfu wsdl-dfuxref wsdl-machine wsdl-smc wsdl-store wsdl-topology wsdl-workunits" }, "dependencies": { "@hpcc-js/ddl-shim": "^2.17.24", @@ -71,6 +84,7 @@ "rollup": "2.10.7", "rollup-plugin-postcss": "3.1.1", "rollup-plugin-sourcemaps": "0.6.2", + "soap": "0.43.0", "terser": "4.0.0", "tslib": "2.3.0", "typedoc": "0.14.2", diff --git a/packages/comms/utils/index.ts b/packages/comms/utils/index.ts new file mode 100644 index 0000000000..a6f18baee6 --- /dev/null +++ b/packages/comms/utils/index.ts @@ -0,0 +1,265 @@ +"use strict"; + +import { mkdirp, writeFile } from "fs-extra"; +import * as path from "path"; +import * as soap from "soap"; +import minimist from "minimist"; + +import { Case, changeCase } from "./util"; + +type JsonObj = { [name: string]: any }; + +const lines: string[] = []; + +const cwd = process.cwd(); + +const args = minimist(process.argv.slice(2)); + +const knownTypes: string[] = []; +const parsedTypes: JsonObj = {}; + +const primitiveMap: { [key: string]: string } = { + "int": "number", + "integer": "number", + "unsignedInt": "number", + "nonNegativeInteger": "number", + "long": "number", + "double": "number", + "base64Binary": "number[]", + "dateTime": "string", +} +const knownPrimitives: string[] = []; + +const parsedEnums: JsonObj = {}; + +const debug = args?.debug ?? false; +const printToConsole = args?.print ?? false; +const outDir = args?.outDir ? args?.outDir : "./temp/wsdl"; + +const ignoredWords = ["targetNSAlias", "targetNamespace"]; + +function printDbg(...args: any[]) { + if (debug) { + console.log(...args); + } +} + +function wsdlToTs(uri: string) { + return new Promise((resolve, reject) => { + soap.createClient(uri, {}, (err, client) => { + if (err) reject(err); + resolve(client); + }); + }).then(client => { + const wsdlDescr = client.describe(); + return [client.wsdl, wsdlDescr]; + }); +} + +function printUsage() { + console.log("Usage: node ./lib-cjs/index.ts --uri=someUri\n"); + console.log("Available flags: "); + console.log("===================="); + console.log("--uri=someUri\t\t\tA URI for a WSDL to be converted to TypeScript interfaces (either URL or /path/to/file)"); + console.log("--outDir=./some/path\t\tThe directory into which the generated TS interfaces will be written (defaults to \"./temp/wsdl/{version}/\")."); + console.log("--print\t\t\t\tRather than writing files, print the generated TS interfaces to the CLI"); +} + +if (!args.url) { + console.error("No WSDL URI provided.\n"); + printUsage(); + process.exit(0); +} + +if (args.help) { + printUsage(); + process.exit(0); +} + +function parseEnum(enumString: string, enumEl) { + const enumParts = enumString.split("|"); + printDbg(`parsing enum parts ${enumParts[0]}`, enumParts); + return { + type: enumParts[0], + enumType: enumParts[1].replace(/xsd:/, ""), + values: enumParts[2].split(",").map((v, idx) => { + const member = v.split(" ").map(w => changeCase(w, Case.PascalCase)).join(""); + if (enumParts[1].replace(/xsd:/, "") === "int") { + let memberName = ""; + enumEl.children.filter(el => el.name === "annotation")[0].children.forEach(el => { + memberName = changeCase(el.children[idx].$description, Case.PascalCase).replace(/ /g, ""); + }); + return `${memberName} = ${member}`; + } + return `${member} = "${member}"`; + }) + }; +} + +function parseTypeDefinition(operation: JsonObj, opName: string, types) { + + const typeDefn: JsonObj = {}; + printDbg(`processing ${opName}`, operation); + for (const prop in operation) { + const propName = (!prop.endsWith("[]")) ? prop : prop.slice(0, -2); + if (typeof operation[prop] === "object") { + const op = operation[prop]; + if (knownTypes.indexOf(propName) < 0) { + knownTypes.push(propName); + const defn = parseTypeDefinition(op, propName, types); + if (prop.endsWith("[]")) { + typeDefn[propName] = prop; + } else { + typeDefn[propName] = defn; + } + parsedTypes[propName] = defn; + } else { + typeDefn[propName] = prop; + } + + } else { + if (ignoredWords.indexOf(prop) < 0) { + const primitiveType = operation[prop].replace(/xsd:/gi, ""); + if (prop.indexOf("[]") > 0) { + typeDefn[prop.slice(0, -2)] = primitiveType + "[]"; + } else if (operation[prop].match(/[.*\|.*\|.*]/)) { + // note: the above regex is matching the node soap stringified + // structure of enums, parsed by client.describe(), + // e.g.: SomeEnumIdentifier|xsd:int|1,2,3,4 + const enumTypeName = operation[prop].split("|")[0] + const { type, enumType, values } = parseEnum(operation[prop], types[enumTypeName]); + parsedEnums[type] = values; + typeDefn[prop] = type; + } else { + typeDefn[prop] = primitiveType; + } + if (Object.keys(primitiveMap).indexOf(primitiveType) > -1 && knownPrimitives.indexOf(primitiveType) < 0) { + knownPrimitives.push(primitiveType); + } + } + } + } + + if (knownTypes.indexOf(opName) < 0) { + knownTypes.push(opName); + parsedTypes[opName] = typeDefn; + } + return typeDefn; +} + +wsdlToTs(args.url) + .then(clientObjs => { + const [wsdl, descr] = clientObjs; + const bindings = wsdl.definitions.bindings; + const wsdlNS = wsdl.definitions.$targetNamespace; + let namespace = ""; + for (const ns in descr) { + namespace = changeCase(ns, Case.PascalCase); + const service = descr[ns]; + printDbg("namespace: ", namespace, "\n"); + for (const op in service) { + printDbg("binding: ", changeCase(op, Case.PascalCase), "\n"); + const binding = service[op]; + for (const svc in binding) { + const operation = binding[svc]; + const types = wsdl.definitions.schemas[wsdlNS].types; + const request = operation["input"]; + const reqName = bindings[op].methods[svc].input.$name; + const response = operation["output"]; + const respName = bindings[op].methods[svc].output.$name; + + parseTypeDefinition(request, reqName, types); + parseTypeDefinition(response, respName, types); + } + } + } + + knownPrimitives.forEach(primitive => { + lines.push(`type ${primitive} = ${primitiveMap[primitive]};`); + }); + lines.push("\n\n"); + + for (const name in parsedEnums) { + lines.push(`export enum ${name} { + ${parsedEnums[name].join(",\n")} + }\n`); + } + + lines.push(`export namespace ${namespace} {\n`); + + for (const type in parsedTypes) { + lines.push(`export interface ${type} {\n`); + let typeString = JSON.stringify(parsedTypes[type], null, 4) // convert object to string + .replace(/"/g, "") // remove double-quotes from JSON keys & values + .replace(/,?\n/g, ";\n") // replace comma delimiters with semi-colons + .replace(/\{;/g, "{"); // correct lines where ; added erroneously + + if (type.endsWith("Request")) { + typeString = typeString.replace(/:/g, "?:"); // make request properties optional + } + lines.push(typeString.substring(1, typeString.length - 1) + "\n"); + lines.push("}\n"); + } + + lines.push("}"); + + lines.push("\n\n"); + + lines.push(`export class ${namespace.replace("Ws", "")}Service extends Service {\n`); + + const methods = []; + + for (const service in bindings) { + const binding = bindings[service]; + for (const method in binding.methods) { + const soapAction = binding.methods[method].soapAction; + // a domain name is required by Node's URL object for parsing searchParams, etc + const url = `https://example.org/${soapAction}`; + const inputName = binding.methods[method].input["$name"]; + const outputName = binding.methods[method].output["$name"]; + methods.push({ + url: soapAction, + version: new URL(url).searchParams.get("ver_"), + name: method, + input: inputName, + output: outputName + }); + } + } + + const serviceVersion = `v${methods[0]?.version}` ?? ""; + const finalPath = path.join(outDir, namespace, serviceVersion); + const relativePath = path.relative(path.join(cwd, finalPath), path.join(cwd, "./src")).replace(/\\/g, "/"); + lines.unshift("\n\n"); + lines.unshift(`import { Service } from "${relativePath}/espConnection";`); + lines.unshift(`import { IConnection, IOptions } from "${relativePath}/connection";`); + + if (methods.length > 0) { + lines.push("constructor(optsConnection: IOptions | IConnection) {"); + lines.push(`super(optsConnection, "${namespace}", "${methods[0].version}");`); + lines.push("}"); + lines.push("\n\n"); + + methods.forEach(method => { + lines.push(`${method.name}(request: ${namespace}.${method.input}): Promise<${namespace}.${method.output}> {`); + lines.push(`\treturn this._connection.send("${method.name}", request);`); + lines.push("}\n"); + }) + } + + lines.push("}\n"); + + if (printToConsole) { + console.log(lines.join("\n").replace(/\n\n\n/g, "\n")); + } else { + mkdirp(finalPath).then(() => { + const tsFile = path.join(finalPath, namespace + ".ts"); + writeFile(tsFile, lines.join("\n").replace(/\n\n\n/g, "\n"), (err) => { + if (err) throw err; + }) + }) + } + }).catch(err => { + console.error(err); + process.exitCode = -1; + }); \ No newline at end of file diff --git a/packages/comms/utils/util.ts b/packages/comms/utils/util.ts new file mode 100644 index 0000000000..758756d3eb --- /dev/null +++ b/packages/comms/utils/util.ts @@ -0,0 +1,49 @@ +export enum Case { + CamelCase, + PascalCase, + SnakeCase +} + +function splitWords(input: string) { + /* regex from lodash words() function */ + const reAsciiWord = /[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g; + return input.match(reAsciiWord) || []; +} + +function capitalizeWord(input: string) { + return input.charAt(0).toUpperCase() + input.substring(1); +} + +export function changeCase(input: string, toCase: Case) { + let output = input; + let convertString; + switch (toCase) { + case Case.PascalCase: + convertString = (_in: string) => { + const words = splitWords(_in).map(w => { + return capitalizeWord(w); + }) || []; + return words.join(""); + }; + break; + case Case.CamelCase: + convertString = (_in: string) => { + const words = splitWords(_in).map((w, idx) => { + if (idx === 0) return w; + return capitalizeWord(w); + }) || []; + return words.join(""); + } + break; + case Case.SnakeCase: + convertString = (_in: string) => { + return splitWords(_in) + .map(w => w.toLowerCase()) + .join("_"); + } + } + if (typeof convertString === "function") { + output = convertString(input); + } + return output; +} \ No newline at end of file