Skip to content

Commit 68d31e2

Browse files
committed
fix(comms): refactor wsdl generation to use minOccurs
* refactors the wsdl->ts generation utility to respect the "minOccurs" properties in WSDL definitions * exposes an optional "abortSignal" param to all service endpoints, so that consumers can define their own AbortSignals and they be passed thru to ESPConnection.send(...) * tightens up several instances of looser typing Signed-off-by: Jeremy Clements <79224539+jeclrsg@users.noreply.github.com>
1 parent eab3a48 commit 68d31e2

1 file changed

Lines changed: 151 additions & 37 deletions

File tree

packages/comms/utils/index.ts

Lines changed: 151 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,36 @@ import * as tsfmt from "typescript-formatter";
1010
import { Case, changeCase } from "./util";
1111
import { hashSum } from "@hpcc-js/util";
1212

13-
type JsonObj = { [name: string]: any };
13+
interface SoapSchema {
14+
elements?: Record<string, unknown>;
15+
complexTypes?: Record<string, unknown>;
16+
types?: Record<string, unknown>;
17+
}
18+
19+
interface WsdlNode {
20+
name?: string;
21+
children?: WsdlNode[];
22+
$name?: string;
23+
$type?: string;
24+
$minOccurs?: string;
25+
$description?: string;
26+
}
27+
28+
interface SoapBinding {
29+
methods: Record<string, {
30+
soapAction: string;
31+
input: { $name?: string };
32+
output: { $name?: string };
33+
}>;
34+
}
35+
36+
interface ServiceMethod {
37+
url: string;
38+
version: string | null;
39+
name: string;
40+
input: string;
41+
output: string;
42+
}
1443

1544
const lines: string[] = [];
1645

@@ -19,8 +48,9 @@ const cwd = process.cwd();
1948
const args = minimist(process.argv.slice(2));
2049
const keepGoing = args.k === true || args["keep-going"] === true;
2150

22-
const knownTypes: { [name: string]: [string, any] } = {};
23-
const parsedTypes: JsonObj = {};
51+
const knownTypes: { [name: string]: [string, Record<string, unknown> | undefined] } = {};
52+
const parsedTypes: Record<string, unknown> = {};
53+
const parsedOptionals: { [name: string]: Set<string> } = {};
2454

2555
const primitiveMap: { [key: string]: string } = {
2656
"int": "number",
@@ -35,11 +65,11 @@ const primitiveMap: { [key: string]: string } = {
3565
};
3666
const knownPrimitives: string[] = [];
3767

38-
const parsedEnums: JsonObj = {};
68+
const parsedEnums: Record<string, string[]> = {};
3969

40-
const debug = args?.debug ?? false;
41-
const printToConsole = args?.print ?? false;
42-
const outDir = args?.outDir ? args?.outDir : "./temp/wsdl";
70+
const debug: boolean = !!args?.debug;
71+
const printToConsole: boolean = !!args?.print;
72+
const outDir: string = args?.outDir ?? "./temp/wsdl";
4373

4474
const ignoredWords = ["targetNSAlias", "targetNamespace"];
4575

@@ -48,13 +78,39 @@ const tsFmtOpts = {
4878
editorconfig: true, vscode: true, vscodeFile: null, tsfmt: false, tsfmtFile: null
4979
};
5080

51-
function printDbg(...args: any[]) {
81+
function printDbg(...args: unknown[]) {
5282
if (debug) {
5383
console.log(...args);
5484
}
5585
}
5686

57-
function wsdlToTs(uri: string) {
87+
/**
88+
* Recursively collect element names from a node-soap schema node that have minOccurs="0".
89+
* Descends into sequence / all / choice / complexContent / extension / restriction.
90+
*/
91+
function extractOptionalFieldNames(node: WsdlNode | undefined): Set<string> {
92+
const result = new Set<string>();
93+
if (!node?.children) return result;
94+
for (const child of node.children) {
95+
if (child.name === "sequence" || child.name === "all" || child.name === "choice") {
96+
for (const el of (child.children || [])) {
97+
if (el.name === "element" && el.$name && el.$minOccurs === "0") {
98+
result.add(el.$name);
99+
}
100+
}
101+
} else if (
102+
child.name === "complexContent" ||
103+
child.name === "extension" ||
104+
child.name === "restriction" ||
105+
child.name === "complexType"
106+
) {
107+
extractOptionalFieldNames(child).forEach(f => result.add(f));
108+
}
109+
}
110+
return result;
111+
}
112+
113+
function wsdlToTs(uri: string): Promise<[soap.WSDL, any]> {
58114
return new Promise<soap.Client>((resolve, reject) => {
59115
soap.createClient(uri, {}, (err, client) => {
60116
if (err) reject(err);
@@ -87,7 +143,7 @@ if (args.help) {
87143
process.exit(0);
88144
}
89145

90-
function parseEnum(enumString: string, enumEl) {
146+
function parseEnum(enumString: string, enumEl: WsdlNode) {
91147
const enumParts = enumString.split("|");
92148
printDbg(`parsing enum parts ${enumParts[0]}`, enumParts);
93149
return {
@@ -97,8 +153,8 @@ function parseEnum(enumString: string, enumEl) {
97153
const member = v.split(" ").join("");
98154
if (enumParts[1].replace(/xsd:/, "") === "int") {
99155
let memberName = "";
100-
enumEl.children.filter(el => el.name === "annotation")[0].children.forEach(el => {
101-
memberName = changeCase(el.children[idx].$description, Case.PascalCase).replace(/[ ,]/g, "");
156+
enumEl.children?.filter((el: WsdlNode) => el.name === "annotation")[0].children?.forEach((el: WsdlNode) => {
157+
memberName = changeCase(el.children?.[idx]?.$description ?? "", Case.PascalCase).replace(/[ ,]/g, "");
102158
});
103159
return `${memberName} = ${member}`;
104160
}
@@ -107,7 +163,50 @@ function parseEnum(enumString: string, enumEl) {
107163
};
108164
}
109165

110-
function parseTypeDefinition(operation: JsonObj, opName: string, types, isResponse: boolean) {
166+
/**
167+
* Look up a type definition node in the WSDL schema. Types can live in
168+
* schema.elements, schema.complexTypes, or schema.types.
169+
*/
170+
function schemaLookup(schema: SoapSchema, name: string): WsdlNode {
171+
return (schema.elements?.[name] ?? schema.complexTypes?.[name] ?? schema.types?.[name]) as WsdlNode;
172+
}
173+
174+
/**
175+
* Given a schema node (element or complexType), find the schema info for a child element by name.
176+
* Returns either:
177+
* - { typeName: string } when the child has a $type attribute (e.g. "tns:TimeRange" -> "TimeRange")
178+
* - { inlineNode: WsdlNode } when the child has an inline anonymous complexType definition
179+
* - undefined when not found
180+
*/
181+
function findChildElementSchema(node: WsdlNode, childName: string): { typeName: string } | { inlineNode: WsdlNode } | undefined {
182+
if (!node?.children) return undefined;
183+
for (const child of node.children) {
184+
if (child.name === "all" || child.name === "sequence" || child.name === "choice") {
185+
for (const el of (child.children || [])) {
186+
if (el.name === "element" && el.$name === childName) {
187+
if (el.$type) {
188+
return { typeName: el.$type.replace(/^tns:/, "") };
189+
}
190+
// Inline anonymous complexType — return the element node itself
191+
// (it contains the complexType as a child)
192+
return { inlineNode: el };
193+
}
194+
}
195+
} else if (
196+
child.name === "complexType" ||
197+
child.name === "complexContent" ||
198+
child.name === "extension" ||
199+
child.name === "restriction"
200+
) {
201+
const result = findChildElementSchema(child, childName);
202+
if (result) return result;
203+
}
204+
}
205+
return undefined;
206+
}
207+
208+
function parseTypeDefinition(operation: Record<string, unknown>, opName: string, schema: SoapSchema, isResponse: boolean, schemaNodeOverride?: WsdlNode): [string, Record<string, unknown> | undefined] {
209+
const types = schema.types ?? {};
111210
const hashId = hashSum({ opName, operation });
112211
if (knownTypes[hashId]) {
113212
return knownTypes[hashId];
@@ -118,17 +217,25 @@ function parseTypeDefinition(operation: JsonObj, opName: string, types, isRespon
118217
newPropName = `${opName}${i++}`;
119218
}
120219
knownTypes[hashId] = [newPropName, undefined];
121-
const typeDefn: JsonObj = {};
220+
const typeDefn: Record<string, unknown> = {};
221+
const parentSchemaNode = schemaNodeOverride ?? schemaLookup(schema, opName);
122222
printDbg(`processing ${opName}`, operation);
123223
for (const prop in operation) {
124224
const propName = (!prop.endsWith("[]")) ? prop : prop.slice(0, -2);
125225
if (typeof operation[prop] === "object") {
126-
const op = operation[prop];
226+
const op = operation[prop] as Record<string, unknown>;
127227
const keys = Object.keys(op);
128228
if (!isResponse && keys?.length === 1 && keys[0].indexOf("[]") >= 0 && Object.values(op)[0] === "xsd:string") {
129229
typeDefn[propName] = "string[]";
130230
} else {
131-
const [newPropName, defn] = parseTypeDefinition(op, propName, types, isResponse);
231+
const childSchema = findChildElementSchema(parentSchemaNode, propName);
232+
let childOverride: WsdlNode | undefined;
233+
if (childSchema && "typeName" in childSchema) {
234+
childOverride = schemaLookup(schema, childSchema.typeName);
235+
} else if (childSchema && "inlineNode" in childSchema) {
236+
childOverride = childSchema.inlineNode;
237+
}
238+
const [newPropName] = parseTypeDefinition(op, propName, schema, isResponse, childOverride);
132239
if (prop.endsWith("[]")) {
133240
typeDefn[propName] = newPropName + "[]";
134241
} else {
@@ -137,15 +244,16 @@ function parseTypeDefinition(operation: JsonObj, opName: string, types, isRespon
137244
}
138245
} else {
139246
if (ignoredWords.indexOf(prop) < 0) {
140-
const primitiveType = operation[prop].replace(/xsd:/gi, "");
247+
const propValue = operation[prop] as string;
248+
const primitiveType = propValue.replace(/xsd:/gi, "");
141249
if (prop.indexOf("[]") > 0) {
142250
typeDefn[prop.slice(0, -2)] = primitiveType + "[]";
143-
} else if (operation[prop].match(/[.*\|.*\|.*]/)) {
251+
} else if (propValue.match(/[.*\|.*\|.*]/)) {
144252
// note: the above regex is matching the node soap stringified
145253
// structure of enums, parsed by client.describe(),
146254
// e.g.: SomeEnumIdentifier|xsd:int|1,2,3,4
147-
const enumTypeName = operation[prop].split("|")[0];
148-
const { type, enumType, values } = parseEnum(operation[prop], types[enumTypeName]);
255+
const enumTypeName = propValue.split("|")[0];
256+
const { type, enumType, values } = parseEnum(propValue, types[enumTypeName] as WsdlNode);
149257
parsedEnums[type] = values;
150258
typeDefn[prop] = type;
151259
} else {
@@ -159,14 +267,15 @@ function parseTypeDefinition(operation: JsonObj, opName: string, types, isRespon
159267
}
160268
knownTypes[hashId] = [newPropName, typeDefn];
161269
parsedTypes[newPropName] = typeDefn;
270+
parsedOptionals[newPropName] = extractOptionalFieldNames(parentSchemaNode);
162271
return [newPropName, typeDefn];
163272
}
164273
}
165274

166275
wsdlToTs(args.url)
167276
.then(clientObjs => {
168277
const [wsdl, descr] = clientObjs;
169-
const bindings = wsdl.definitions.bindings;
278+
const bindings: Record<string, SoapBinding> = wsdl.definitions.bindings;
170279
const wsdlNS = wsdl.definitions.$targetNamespace;
171280
let namespace = "";
172281
let origNS = "";
@@ -180,17 +289,18 @@ wsdlToTs(args.url)
180289
const binding = service[op];
181290
for (const svc in binding) {
182291
const operation = binding[svc];
183-
const types = wsdl.definitions.schemas[wsdlNS].types;
292+
const schema = wsdl.definitions.schemas[wsdlNS];
184293
const request = operation["input"];
185294
const reqName = bindings[op].methods[svc].input.$name;
186295
const response = operation["output"];
187296
const respName = bindings[op].methods[svc].output.$name;
188297

189-
parseTypeDefinition(request, reqName, types, false);
190-
parseTypeDefinition(response, respName, types, true);
298+
parseTypeDefinition(request, String(reqName), schema, false);
299+
parseTypeDefinition(response, String(respName), schema, true);
191300
}
192301
}
193302
}
303+
194304
lines.push("\n");
195305

196306
lines.push(`export namespace ${namespace} {\n`);
@@ -208,14 +318,18 @@ wsdlToTs(args.url)
208318

209319
for (const type in parsedTypes) {
210320
lines.push(`export interface ${type} {\n`);
211-
let typeString = JSON.stringify(parsedTypes[type], null, 4) // convert object to string
212-
.replace(/"/g, "") // remove double-quotes from JSON keys & values
213-
.replace(/,?\n/g, ";\n") // replace comma delimiters with semi-colons
214-
.replace(/\{;/g, "{"); // correct lines where ; added erroneously
215-
216-
if (type.endsWith("Request")) {
217-
typeString = typeString.replace(/:/g, "?:"); // make request properties optional
218-
}
321+
const optionalSet = parsedOptionals[type] ?? new Set<string>();
322+
let typeString = JSON.stringify(parsedTypes[type], null, 4) // convert object to string
323+
.replace(/"/g, "") // remove double-quotes from JSON keys & values
324+
.replace(/,?\n/g, ";\n") // replace comma delimiters with semi-colons
325+
.replace(/\{;/g, "{") // correct lines where ; added erroneously
326+
.split("\n").map(line => { // mark minOccurs="0" fields as optional
327+
const match = line.match(/^(\s+)(\w+):/);
328+
if (match && optionalSet.has(match[2])) {
329+
return line.replace(match[2] + ":", match[2] + "?:");
330+
}
331+
return line;
332+
}).join("\n");
219333
lines.push(typeString.substring(1, typeString.length - 1) + "\n");
220334
lines.push("}\n");
221335
}
@@ -226,7 +340,7 @@ wsdlToTs(args.url)
226340

227341
lines.push(`export class ${namespace.replace("Ws", "")}ServiceBase extends Service {\n`);
228342

229-
const methods: JsonObj = [];
343+
const methods: ServiceMethod[] = [];
230344

231345
for (const service in bindings) {
232346
const binding = bindings[service];
@@ -240,8 +354,8 @@ wsdlToTs(args.url)
240354
url: soapAction,
241355
version: new URL(url).searchParams.get("ver_"),
242356
name: method,
243-
input: inputName,
244-
output: outputName
357+
input: String(inputName),
358+
output: String(outputName)
245359
});
246360
}
247361
}
@@ -260,8 +374,8 @@ wsdlToTs(args.url)
260374
lines.push("\n\n");
261375

262376
methods.forEach(method => {
263-
lines.push(`${method.name}(request: Partial<${namespace}.${method.input}>): Promise<${namespace}.${method.output}> {`);
264-
lines.push(`\treturn this._connection.send("${method.name}", request, "json", false, undefined, "${method.output}");`);
377+
lines.push(`${method.name}(request: Partial<${namespace}.${method.input}>, abortSignal?: AbortSignal): Promise<${namespace}.${method.output}> {`);
378+
lines.push(`\treturn this._connection.send("${method.name}", request, "json", false, abortSignal, "${method.output}");`);
265379
lines.push("}\n");
266380
});
267381
}

0 commit comments

Comments
 (0)