@techspokes/typescript-wsdl-client 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/app/generateApp.d.ts.map +1 -1
- package/dist/app/generateApp.js +1 -0
- package/dist/cli.js +46 -2
- package/dist/client/generateClient.d.ts.map +1 -1
- package/dist/client/generateClient.js +62 -6
- package/dist/client/generateOperations.d.ts.map +1 -1
- package/dist/client/generateOperations.js +27 -4
- package/dist/compiler/schemaCompiler.d.ts +40 -11
- package/dist/compiler/schemaCompiler.d.ts.map +1 -1
- package/dist/compiler/schemaCompiler.js +81 -6
- package/dist/compiler/shapeResolver.d.ts +18 -0
- package/dist/compiler/shapeResolver.d.ts.map +1 -0
- package/dist/compiler/shapeResolver.js +280 -0
- package/dist/gateway/generateGateway.d.ts.map +1 -1
- package/dist/gateway/generateGateway.js +2 -1
- package/dist/gateway/generators.d.ts +13 -1
- package/dist/gateway/generators.d.ts.map +1 -1
- package/dist/gateway/generators.js +98 -13
- package/dist/gateway/helpers.d.ts +16 -0
- package/dist/gateway/helpers.d.ts.map +1 -1
- package/dist/gateway/helpers.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/openapi/generateOpenAPI.d.ts.map +1 -1
- package/dist/openapi/generateOpenAPI.js +28 -0
- package/dist/pipeline.d.ts +13 -0
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +17 -1
- package/dist/runtime/ndjson.d.ts +24 -0
- package/dist/runtime/ndjson.d.ts.map +1 -0
- package/dist/runtime/ndjson.js +30 -0
- package/dist/runtime/streamXml.d.ts +45 -0
- package/dist/runtime/streamXml.d.ts.map +1 -0
- package/dist/runtime/streamXml.js +212 -0
- package/dist/test/generators.d.ts.map +1 -1
- package/dist/test/generators.js +50 -0
- package/dist/test/mockData.d.ts +6 -0
- package/dist/test/mockData.d.ts.map +1 -1
- package/dist/test/mockData.js +6 -2
- package/dist/util/cli.d.ts.map +1 -1
- package/dist/util/cli.js +3 -1
- package/dist/util/runtimeSource.d.ts +2 -0
- package/dist/util/runtimeSource.d.ts.map +1 -0
- package/dist/util/runtimeSource.js +38 -0
- package/dist/util/streamConfig.d.ts +59 -0
- package/dist/util/streamConfig.d.ts.map +1 -0
- package/dist/util/streamConfig.js +230 -0
- package/docs/README.md +1 -0
- package/docs/api-reference.md +146 -0
- package/docs/architecture.md +27 -5
- package/docs/cli-reference.md +30 -0
- package/docs/concepts.md +51 -0
- package/docs/configuration.md +40 -0
- package/docs/decisions/002-streamable-responses.md +308 -0
- package/docs/gateway-guide.md +37 -0
- package/docs/generated-code.md +21 -0
- package/docs/migration-playbook.md +33 -0
- package/docs/migration.md +31 -6
- package/docs/output-anatomy.md +49 -0
- package/docs/production.md +32 -0
- package/docs/start-here.md +33 -0
- package/docs/supported-patterns.md +2 -0
- package/docs/testing.md +14 -0
- package/docs/troubleshooting.md +18 -0
- package/package.json +5 -2
- package/src/runtime/clientStreamMethods.tpl.txt +183 -0
- package/src/runtime/ndjson.ts +32 -0
- package/src/runtime/operationsStreamHelper.tpl.txt +13 -0
- package/src/runtime/streamXml.ts +293 -0
package/docs/testing.md
CHANGED
|
@@ -246,6 +246,20 @@ const client = createMockClient({
|
|
|
246
246
|
|
|
247
247
|
Mock responses use the pre-unwrap SOAP wrapper shape. The generated `unwrapArrayWrappers()` function handles conversion at runtime.
|
|
248
248
|
|
|
249
|
+
For operations opted in via `--stream-config`, the mock returns `records: AsyncIterable<RecordType>` (via a small `asyncIterableOf` helper) and the generated happy-path test asserts on the NDJSON content-type and parseable record lines. Override a stream op with a multi-record iterable to exercise downstream backpressure:
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
const client = createMockClient({
|
|
253
|
+
UnitDescriptiveInfoStream: async () => ({
|
|
254
|
+
records: (async function* () {
|
|
255
|
+
yield { Id: "1", Name: "Villa A" };
|
|
256
|
+
yield { Id: "2", Name: "Villa B" };
|
|
257
|
+
})(),
|
|
258
|
+
headers: {},
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
249
263
|
## For Consumer Projects
|
|
250
264
|
|
|
251
265
|
If you're using wsdl-tsc as a dependency and want to test your integration:
|
package/docs/troubleshooting.md
CHANGED
|
@@ -17,6 +17,12 @@ See [README](../README.md) for quick start and [CLI Reference](cli-reference.md)
|
|
|
17
17
|
| TypeScript compilation errors | Check --import-extensions matches your tsconfig moduleResolution |
|
|
18
18
|
| Gateway validation failures | Ensure OpenAPI has valid $ref paths and all schemas in components.schemas |
|
|
19
19
|
| Catalog file not found | Catalog defaults to output directory; use --catalog-file to specify |
|
|
20
|
+
| Stream config references unknown operation | Operation name must match the WSDL exactly; check spelling and casing |
|
|
21
|
+
| Stream record type not found | `recordType` must exist in the main catalog or a companion `shapeCatalog` must supply it; confirm the companion WSDL compiles cleanly in isolation |
|
|
22
|
+
| Structural collision between main and companion catalog | Two types share a name but differ structurally; rename in the companion source or point `recordType` at a distinct subtree |
|
|
23
|
+
| NDJSON response ends abruptly | Mid-stream upstream error per the terminal-error policy; check gateway logs for the classified error |
|
|
24
|
+
| Stream recordPath does not match | SAX matching is positional and case-sensitive; verify duplicate local-name segments are spelled exactly |
|
|
25
|
+
| Stream client throws "stream request failed" | The upstream SOAP endpoint rejected the hand-built envelope; check `requestRaw` on the response and verify SOAP action and namespaces match the WSDL binding |
|
|
20
26
|
|
|
21
27
|
## SOAP Wire Logging
|
|
22
28
|
|
|
@@ -68,3 +74,15 @@ Or inspect catalog from client generation:
|
|
|
68
74
|
```bash
|
|
69
75
|
cat ./src/services/hotel/catalog.json | jq '.types'
|
|
70
76
|
```
|
|
77
|
+
|
|
78
|
+
## Streaming Debug
|
|
79
|
+
|
|
80
|
+
Inspect stream metadata on the compiled catalog to confirm the config was parsed and applied:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cat ./src/services/hotel/catalog.json | jq '.operations[] | select(.stream) | {name, stream}'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Each entry shows the normalized `OperationStreamMetadata` (mode, format, mediaType, recordPath, recordTypeName, and any `shapeCatalogName`). If an expected operation is missing, the config either did not match the WSDL operation name or was not passed to the generation command.
|
|
87
|
+
|
|
88
|
+
For record-path and chunk-boundary issues, the reference integration test pattern lives in `test/integration/stream-end-to-end.test.ts` and the SAX record matcher is exercised by `test/unit/stream-xml.test.ts`. Running them against a local fixture isolates whether the issue is in the config, the parser, or the upstream SOAP server.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techspokes/typescript-wsdl-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Turn legacy WSDL/SOAP services into typed TypeScript clients, OpenAPI 3.1 specs, and production-ready Fastify REST gateways. Built for enterprise SOAP modernization.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wsdl",
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"docs",
|
|
54
54
|
"README.md",
|
|
55
55
|
"LICENSE",
|
|
56
|
-
"llms.txt"
|
|
56
|
+
"llms.txt",
|
|
57
|
+
"src/runtime/*.ts",
|
|
58
|
+
"src/runtime/*.tpl.txt"
|
|
57
59
|
],
|
|
58
60
|
"scripts": {
|
|
59
61
|
"dev": "tsx src/cli.ts",
|
|
@@ -93,6 +95,7 @@
|
|
|
93
95
|
"@apidevtools/swagger-parser": "^12.1.0",
|
|
94
96
|
"fast-xml-parser": "^5.7.1",
|
|
95
97
|
"js-yaml": "^4.1.1",
|
|
98
|
+
"saxes": "^6.0.0",
|
|
96
99
|
"soap": "^1.9.1",
|
|
97
100
|
"yargs": "^18.0.0"
|
|
98
101
|
},
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Streaming transport for operations flagged with a stream configuration.
|
|
4
|
+
*
|
|
5
|
+
* node-soap buffers the full response before invoking the operation callback
|
|
6
|
+
* (verified empirically in ADR-002 / phase-0 research), so this method
|
|
7
|
+
* bypasses node-soap and POSTs a hand-built SOAP envelope directly, then
|
|
8
|
+
* pipes the response body through the SAX-driven record parser.
|
|
9
|
+
*/
|
|
10
|
+
protected async callStream<RequestType, RecordType, HeadersType>(
|
|
11
|
+
args: RequestType,
|
|
12
|
+
operation: string,
|
|
13
|
+
requestType: string | undefined,
|
|
14
|
+
recordTypeName: string,
|
|
15
|
+
inputElementLocal: string,
|
|
16
|
+
inputElementNs: string,
|
|
17
|
+
soapAction: string,
|
|
18
|
+
recordPath: string[]
|
|
19
|
+
): Promise<StreamOperationResponse<RecordType, HeadersType>> {
|
|
20
|
+
const client = await this.soapClient();
|
|
21
|
+
const endpoint = this.resolveStreamEndpoint(client);
|
|
22
|
+
const envelope = this.buildSoapEnvelope(args, operation, requestType, inputElementLocal, inputElementNs);
|
|
23
|
+
const res = await fetch(endpoint, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
"content-type": "text/xml; charset=utf-8",
|
|
27
|
+
"soapaction": '"' + soapAction + '"',
|
|
28
|
+
},
|
|
29
|
+
body: envelope,
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
operation + " stream request failed: " + res.status + " " + res.statusText
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (!res.body) {
|
|
37
|
+
throw new Error(operation + " stream request returned an empty response body");
|
|
38
|
+
}
|
|
39
|
+
const headers: Record<string, string> = {};
|
|
40
|
+
res.headers.forEach((value, key) => { headers[key] = value; });
|
|
41
|
+
const spec: RecordParseSpec = {
|
|
42
|
+
recordPath,
|
|
43
|
+
recordTypeName,
|
|
44
|
+
attributesKey: this.attributesKeyOut,
|
|
45
|
+
childType: this.dataTypes?.ChildrenTypes,
|
|
46
|
+
};
|
|
47
|
+
const records = parseRecords<RecordType>(
|
|
48
|
+
this.webStreamToAsyncIterable(res.body),
|
|
49
|
+
spec
|
|
50
|
+
);
|
|
51
|
+
return {
|
|
52
|
+
records,
|
|
53
|
+
headers: headers as unknown as HeadersType,
|
|
54
|
+
requestRaw: envelope,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Walk the node-soap WSDL descriptor to locate the first service port's
|
|
60
|
+
* SOAP address. Falls back to the \`source\` URL when the descriptor is
|
|
61
|
+
* unavailable (e.g., \`source\` was given as a direct endpoint URL).
|
|
62
|
+
*/
|
|
63
|
+
protected resolveStreamEndpoint(client: soap.Client): string {
|
|
64
|
+
const services = (client as any)?.wsdl?.definitions?.services ?? {};
|
|
65
|
+
for (const svc of Object.values(services)) {
|
|
66
|
+
const ports = (svc as any)?.ports ?? {};
|
|
67
|
+
for (const port of Object.values(ports)) {
|
|
68
|
+
const location = (port as any)?.location;
|
|
69
|
+
if (typeof location === "string" && location) return location;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return this.source;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a SOAP 1.1 envelope around the operation's input element. Uses the
|
|
77
|
+
* existing attributes / children metadata so attribute bags render as XML
|
|
78
|
+
* attributes and child properties render as nested elements.
|
|
79
|
+
*/
|
|
80
|
+
protected buildSoapEnvelope(
|
|
81
|
+
args: unknown,
|
|
82
|
+
operation: string,
|
|
83
|
+
requestType: string | undefined,
|
|
84
|
+
inputElementLocal: string,
|
|
85
|
+
inputElementNs: string
|
|
86
|
+
): string {
|
|
87
|
+
const body = this.toXmlElement(args, requestType, inputElementLocal, inputElementNs);
|
|
88
|
+
return '<?xml version="1.0" encoding="utf-8"?>' +
|
|
89
|
+
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
|
|
90
|
+
'<soap:Body>' + body + '</soap:Body>' +
|
|
91
|
+
'</soap:Envelope>';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Serialize a value as a single XML element. The element namespace is only
|
|
96
|
+
* emitted when \`namespace\` is provided (i.e., the envelope's top-level body
|
|
97
|
+
* element). Nested elements inherit the namespace via XML scoping.
|
|
98
|
+
*/
|
|
99
|
+
protected toXmlElement(
|
|
100
|
+
value: unknown,
|
|
101
|
+
typeName: string | undefined,
|
|
102
|
+
elementName: string,
|
|
103
|
+
namespace?: string
|
|
104
|
+
): string {
|
|
105
|
+
const nsAttr = namespace ? ' xmlns="' + this.escapeXml(namespace) + '"' : "";
|
|
106
|
+
if (value === null || value === undefined) {
|
|
107
|
+
return '<' + elementName + nsAttr +
|
|
108
|
+
' xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>';
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== "object") {
|
|
111
|
+
return '<' + elementName + nsAttr + '>' + this.escapeXml(String(value)) + '</' + elementName + '>';
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
return value.map((v) => this.toXmlElement(v, typeName, elementName, namespace)).join("");
|
|
115
|
+
}
|
|
116
|
+
const obj = value as Record<string, unknown>;
|
|
117
|
+
const attributesList = (typeName && this.dataTypes?.Attributes?.[typeName]) || [];
|
|
118
|
+
const childrenTypes = (typeName && this.dataTypes?.ChildrenTypes?.[typeName]) || {};
|
|
119
|
+
const attrPairs: Array<[string, string]> = [];
|
|
120
|
+
const bagIn = (obj as any)[this.attributesKeyIn] ?? (obj as any)["attributes"];
|
|
121
|
+
if (bagIn && typeof bagIn === "object") {
|
|
122
|
+
for (const [k, v] of Object.entries(bagIn)) attrPairs.push([k, this.normalizeAttr(v)]);
|
|
123
|
+
}
|
|
124
|
+
const childParts: string[] = [];
|
|
125
|
+
let textContent: string | undefined;
|
|
126
|
+
if ("$value" in obj) textContent = String(obj.$value ?? "");
|
|
127
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
128
|
+
if (k === "$value" || k === this.attributesKeyIn || k === "attributes") continue;
|
|
129
|
+
if ((attributesList as readonly string[]).includes(k)) {
|
|
130
|
+
attrPairs.push([k, this.normalizeAttr(v)]);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const childTypeRaw = (childrenTypes as Record<string, string>)[k];
|
|
134
|
+
const childType = childTypeRaw?.endsWith("[]") ? childTypeRaw.slice(0, -2) : childTypeRaw;
|
|
135
|
+
if (Array.isArray(v)) {
|
|
136
|
+
for (const item of v) childParts.push(this.toXmlElement(item, childType, k));
|
|
137
|
+
} else {
|
|
138
|
+
childParts.push(this.toXmlElement(v, childType, k));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const attrStr = attrPairs.map(([k, val]) => ' ' + k + '="' + this.escapeXml(val) + '"').join("");
|
|
142
|
+
if (childParts.length === 0 && textContent === undefined) {
|
|
143
|
+
return '<' + elementName + nsAttr + attrStr + '/>';
|
|
144
|
+
}
|
|
145
|
+
const inner = childParts.join("") + (textContent !== undefined ? this.escapeXml(textContent) : "");
|
|
146
|
+
return '<' + elementName + nsAttr + attrStr + '>' + inner + '</' + elementName + '>';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
protected normalizeAttr(v: unknown): string {
|
|
150
|
+
if (v === null || v === undefined) return "";
|
|
151
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
152
|
+
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "";
|
|
153
|
+
return String(v);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected escapeXml(s: string): string {
|
|
157
|
+
return s
|
|
158
|
+
.replace(/&/g, "&")
|
|
159
|
+
.replace(/</g, "<")
|
|
160
|
+
.replace(/>/g, ">")
|
|
161
|
+
.replace(/"/g, """)
|
|
162
|
+
.replace(/'/g, "'");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Iterate an uploaded web ReadableStream as Uint8Array chunks. Node 20+
|
|
167
|
+
* ReadableStream implements Symbol.asyncIterator natively; this helper
|
|
168
|
+
* normalizes the types so TypeScript can drive it through \`for await\`.
|
|
169
|
+
*/
|
|
170
|
+
protected async *webStreamToAsyncIterable(
|
|
171
|
+
body: ReadableStream<Uint8Array>
|
|
172
|
+
): AsyncIterable<Uint8Array> {
|
|
173
|
+
const reader = body.getReader();
|
|
174
|
+
try {
|
|
175
|
+
while (true) {
|
|
176
|
+
const {value, done} = await reader.read();
|
|
177
|
+
if (done) return;
|
|
178
|
+
if (value) yield value;
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
try { reader.releaseLock(); } catch { /* noop */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDJSON adapter for record iterables.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 of ADR-002. Given an `AsyncIterable<T>` (typically the output of
|
|
5
|
+
* `parseRecords`), produce a Node `Readable` that emits one JSON-encoded line
|
|
6
|
+
* per record and respects downstream backpressure.
|
|
7
|
+
*
|
|
8
|
+
* Terminal-error policy (Q3 resolved): the stream aborts on source errors.
|
|
9
|
+
* Before-first-byte errors surface as the stream's `error` event before any
|
|
10
|
+
* bytes are pushed, so Fastify can translate them into a normal JSON error
|
|
11
|
+
* envelope. Errors after the first byte propagate as `error` events too, but
|
|
12
|
+
* callers should treat them as a truncated response.
|
|
13
|
+
*/
|
|
14
|
+
import {Readable} from "node:stream";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wrap an async iterable of records in a Node `Readable` stream that emits
|
|
18
|
+
* NDJSON (one JSON document per line, LF-terminated). Downstream backpressure
|
|
19
|
+
* is honored via `Readable.from`'s default behavior: the iterator's `next()`
|
|
20
|
+
* is not called until the internal buffer has room.
|
|
21
|
+
*
|
|
22
|
+
* Source errors are forwarded to the returned stream's `error` event.
|
|
23
|
+
*/
|
|
24
|
+
export function toNdjson<T>(records: AsyncIterable<T>): Readable {
|
|
25
|
+
return Readable.from(encode(records), {objectMode: false, encoding: "utf-8"});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function* encode<T>(records: AsyncIterable<T>): AsyncIterable<string> {
|
|
29
|
+
for await (const record of records) {
|
|
30
|
+
yield JSON.stringify(record) + "\n";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response shape for streaming __CLIENT_NAME__ operations. `records` is a
|
|
3
|
+
* single-pass async iterable of parsed record objects; iteration pulls bytes
|
|
4
|
+
* from the upstream SOAP response on demand. `headers` is the HTTP response
|
|
5
|
+
* header map captured before the first record is parsed. `requestRaw`, when
|
|
6
|
+
* populated, contains the serialized SOAP envelope that was sent upstream.
|
|
7
|
+
*/
|
|
8
|
+
export type StreamOperationResponse<RecordType, HeadersType = Record<string, unknown>> = {
|
|
9
|
+
records: AsyncIterable<RecordType>;
|
|
10
|
+
headers: HeadersType;
|
|
11
|
+
requestRaw?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming SOAP-payload to record iterator.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 of ADR-002. Driven by saxes (chunk-boundary safe — proven in
|
|
5
|
+
* test/research/sax-record-path.test.ts) and the compiled-catalog metadata
|
|
6
|
+
* that the buffered client already relies on. The parser accepts an async
|
|
7
|
+
* iterable of bytes/strings (typically an upstream SOAP HTTP response) and
|
|
8
|
+
* yields fully-materialized record objects as the corresponding end tags
|
|
9
|
+
* close. Consumers never see partial records.
|
|
10
|
+
*
|
|
11
|
+
* Open questions resolved here:
|
|
12
|
+
* Q3 (terminal error policy): stream aborts. Errors that happen before the
|
|
13
|
+
* first record bubble out as a rejected promise from the first
|
|
14
|
+
* iterator.next(). Errors after a record was emitted throw from a
|
|
15
|
+
* later iterator.next() — callers are expected to treat that as a
|
|
16
|
+
* truncated stream.
|
|
17
|
+
* Q4 (saxes placement): runtime dependency of the wsdl-tsc package; the
|
|
18
|
+
* generated client imports the emitted copy of this module and
|
|
19
|
+
* inherits saxes via its own dependency tree.
|
|
20
|
+
*/
|
|
21
|
+
import {SaxesParser, type SaxesTagPlain} from "saxes";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Catalog-driven parse specification. `recordPath` is an ordered XML element
|
|
25
|
+
* path from the SOAP body payload down to the repeated record element. The
|
|
26
|
+
* path is matched as a suffix of the open-tag stack, so callers may either
|
|
27
|
+
* pre-strip the SOAP envelope or feed the entire response body.
|
|
28
|
+
*
|
|
29
|
+
* Duplicate local names in `recordPath` are supported and expected (Escapia's
|
|
30
|
+
* EVRN content service nests two elements named `EVRN_UnitDescriptiveInfoRS`).
|
|
31
|
+
*/
|
|
32
|
+
export interface RecordParseSpec {
|
|
33
|
+
recordPath: string[];
|
|
34
|
+
/** TypeScript type name of the record, used to look up propMeta. */
|
|
35
|
+
recordTypeName: string;
|
|
36
|
+
/** Attribute bag key to stash XML attributes under. Defaults to "$attributes". */
|
|
37
|
+
attributesKey?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Compiled-catalog child-type map: `childType[typeName][propName] = tsType`.
|
|
40
|
+
* Used to descend into nested complex types and to detect array-valued
|
|
41
|
+
* props (trailing `[]`). Optional — absent means occurrence-based array
|
|
42
|
+
* detection only.
|
|
43
|
+
*/
|
|
44
|
+
childType?: Record<string, Record<string, string>>;
|
|
45
|
+
/**
|
|
46
|
+
* Compiled-catalog prop-meta map: carries min/max/nillable/declaredType.
|
|
47
|
+
* When present, `max > 1` or `max === "unbounded"` drives array emission
|
|
48
|
+
* even for props that happen to occur just once in a given record.
|
|
49
|
+
*/
|
|
50
|
+
propMeta?: Record<string, Record<string, PropMeta>>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PropMeta {
|
|
54
|
+
min?: number;
|
|
55
|
+
max?: number | "unbounded";
|
|
56
|
+
nillable?: boolean;
|
|
57
|
+
declaredType?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Consume an async iterable of XML bytes/strings and yield parsed records.
|
|
62
|
+
*
|
|
63
|
+
* The returned iterator is single-pass; iteration must complete (or be
|
|
64
|
+
* interrupted by the consumer via `break`/`return`) before the upstream
|
|
65
|
+
* source is released. Errors from the source or from the SAX parser abort
|
|
66
|
+
* the iteration via a rejected `next()`.
|
|
67
|
+
*/
|
|
68
|
+
export async function* parseRecords<T = unknown>(
|
|
69
|
+
source: AsyncIterable<string | Uint8Array>,
|
|
70
|
+
spec: RecordParseSpec,
|
|
71
|
+
): AsyncIterable<T> {
|
|
72
|
+
const parser = new SaxesParser({xmlns: false, position: false});
|
|
73
|
+
const recordPath = spec.recordPath;
|
|
74
|
+
if (recordPath.length === 0) {
|
|
75
|
+
throw new Error("parseRecords: recordPath must not be empty");
|
|
76
|
+
}
|
|
77
|
+
const attrsKey = spec.attributesKey ?? "$attributes";
|
|
78
|
+
|
|
79
|
+
// Global tag stack, maintained across the entire document.
|
|
80
|
+
const stack: string[] = [];
|
|
81
|
+
// Records materialized during the current chunk write, awaiting yield.
|
|
82
|
+
const pending: T[] = [];
|
|
83
|
+
// When a parser.on('error') fires we buffer the error for re-throw on the
|
|
84
|
+
// next yield cycle; saxes emits 'error' and continues unless we stop it.
|
|
85
|
+
let parseError: Error | null = null;
|
|
86
|
+
|
|
87
|
+
// While we're inside the record element (stack tail matches recordPath),
|
|
88
|
+
// we maintain a stack of "open" element nodes capturing their XML shape.
|
|
89
|
+
interface OpenNode {
|
|
90
|
+
obj: Record<string, unknown>;
|
|
91
|
+
/** Type name for `childType`/`propMeta` lookup on direct children. */
|
|
92
|
+
typeName: string | null;
|
|
93
|
+
/** Name this node was opened as, in the parent object. null for the record root. */
|
|
94
|
+
propName: string | null;
|
|
95
|
+
/** Accumulated text content (including CDATA). */
|
|
96
|
+
textBuf: string[];
|
|
97
|
+
/** True once we've seen at least one child element — distinguishes leaves from containers. */
|
|
98
|
+
hadChildren: boolean;
|
|
99
|
+
/** xsi:nil="true" marker → materialize as null regardless of content. */
|
|
100
|
+
isNil: boolean;
|
|
101
|
+
}
|
|
102
|
+
const openNodes: OpenNode[] = [];
|
|
103
|
+
|
|
104
|
+
parser.on("opentag", (tag: SaxesTagPlain) => {
|
|
105
|
+
stack.push(tag.name);
|
|
106
|
+
if (openNodes.length === 0) {
|
|
107
|
+
// Not yet inside a record. Check whether entering the path tail.
|
|
108
|
+
if (tailMatches(stack, recordPath)) {
|
|
109
|
+
openNodes.push(newNode(tag, spec.recordTypeName, null, attrsKey));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Inside a record: descend.
|
|
114
|
+
const parent = openNodes[openNodes.length - 1];
|
|
115
|
+
parent.hadChildren = true;
|
|
116
|
+
const parentType = parent.typeName;
|
|
117
|
+
const childTypeRaw = parentType ? spec.childType?.[parentType]?.[tag.name] : undefined;
|
|
118
|
+
const childTypeName = childTypeRaw ? stripArraySuffix(childTypeRaw) : null;
|
|
119
|
+
openNodes.push(newNode(tag, childTypeName, tag.name, attrsKey));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const appendText = (t: string) => {
|
|
123
|
+
if (openNodes.length > 0) openNodes[openNodes.length - 1].textBuf.push(t);
|
|
124
|
+
};
|
|
125
|
+
parser.on("text", appendText);
|
|
126
|
+
parser.on("cdata", appendText);
|
|
127
|
+
|
|
128
|
+
parser.on("closetag", (_tag: SaxesTagPlain) => {
|
|
129
|
+
if (openNodes.length > 0) {
|
|
130
|
+
const closing = openNodes.pop()!;
|
|
131
|
+
const value = materialize(closing, attrsKey);
|
|
132
|
+
if (openNodes.length === 0) {
|
|
133
|
+
// We just closed the record root. Stack tail must match once more.
|
|
134
|
+
if (tailMatches(stack, recordPath)) {
|
|
135
|
+
pending.push(value as T);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
assignChild(openNodes[openNodes.length - 1], closing.propName!, value, spec);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
stack.pop();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
parser.on("error", (err: Error) => {
|
|
145
|
+
parseError = err;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
for await (const chunk of source) {
|
|
150
|
+
if (parseError) throw parseError;
|
|
151
|
+
const text = typeof chunk === "string" ? chunk : decodeUtf8(chunk);
|
|
152
|
+
parser.write(text);
|
|
153
|
+
if (parseError) throw parseError;
|
|
154
|
+
while (pending.length > 0) {
|
|
155
|
+
yield pending.shift()!;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
parser.close();
|
|
159
|
+
if (parseError) throw parseError;
|
|
160
|
+
while (pending.length > 0) {
|
|
161
|
+
yield pending.shift()!;
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
// Best-effort cleanup: detach handlers so the parser can be GC'd even
|
|
165
|
+
// when the consumer aborts iteration early.
|
|
166
|
+
parser.off("opentag");
|
|
167
|
+
parser.off("closetag");
|
|
168
|
+
parser.off("text");
|
|
169
|
+
parser.off("cdata");
|
|
170
|
+
parser.off("error");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function newNode(
|
|
175
|
+
tag: SaxesTagPlain,
|
|
176
|
+
typeName: string | null,
|
|
177
|
+
propName: string | null,
|
|
178
|
+
attrsKey: string,
|
|
179
|
+
): {
|
|
180
|
+
obj: Record<string, unknown>;
|
|
181
|
+
typeName: string | null;
|
|
182
|
+
propName: string | null;
|
|
183
|
+
textBuf: string[];
|
|
184
|
+
hadChildren: boolean;
|
|
185
|
+
isNil: boolean;
|
|
186
|
+
} {
|
|
187
|
+
const attrs = tag.attributes;
|
|
188
|
+
const obj: Record<string, unknown> = {};
|
|
189
|
+
let isNil = false;
|
|
190
|
+
const attrKeys = Object.keys(attrs);
|
|
191
|
+
if (attrKeys.length > 0) {
|
|
192
|
+
// Detect xsi:nil and drop it from the attribute bag — it's a wire-level
|
|
193
|
+
// concern that should not pollute the user-visible record.
|
|
194
|
+
const cleaned: Record<string, string> = {};
|
|
195
|
+
for (const k of attrKeys) {
|
|
196
|
+
if ((k === "xsi:nil" || k === "nil") && attrs[k] === "true") {
|
|
197
|
+
isNil = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
cleaned[k] = attrs[k];
|
|
201
|
+
}
|
|
202
|
+
if (Object.keys(cleaned).length > 0) {
|
|
203
|
+
obj[attrsKey] = cleaned;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
obj,
|
|
208
|
+
typeName,
|
|
209
|
+
propName,
|
|
210
|
+
textBuf: [],
|
|
211
|
+
hadChildren: false,
|
|
212
|
+
isNil,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function materialize(
|
|
217
|
+
node: {obj: Record<string, unknown>; textBuf: string[]; hadChildren: boolean; isNil: boolean},
|
|
218
|
+
attrsKey: string,
|
|
219
|
+
): unknown {
|
|
220
|
+
if (node.isNil) return null;
|
|
221
|
+
if (!node.hadChildren) {
|
|
222
|
+
// Leaf with no child elements: it's simple text. Preserve attributes via
|
|
223
|
+
// a `$value` pairing when present, mirroring how the buffered mapper
|
|
224
|
+
// surfaces simpleContent-with-attributes types.
|
|
225
|
+
const text = node.textBuf.join("");
|
|
226
|
+
if (attrsKey in node.obj) {
|
|
227
|
+
return {...node.obj, $value: text};
|
|
228
|
+
}
|
|
229
|
+
return text;
|
|
230
|
+
}
|
|
231
|
+
return node.obj;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function assignChild(
|
|
235
|
+
parent: {obj: Record<string, unknown>; typeName: string | null},
|
|
236
|
+
propName: string,
|
|
237
|
+
value: unknown,
|
|
238
|
+
spec: RecordParseSpec,
|
|
239
|
+
): void {
|
|
240
|
+
const parentType = parent.typeName;
|
|
241
|
+
const propMetaEntry = parentType ? spec.propMeta?.[parentType]?.[propName] : undefined;
|
|
242
|
+
const childTypeHint = parentType ? spec.childType?.[parentType]?.[propName] : undefined;
|
|
243
|
+
|
|
244
|
+
// Array if: (a) propMeta says max > 1 or "unbounded", or (b) childType hint
|
|
245
|
+
// ends in `[]`, or (c) the slot is already occupied (implicit repetition).
|
|
246
|
+
const metaSaysArray =
|
|
247
|
+
propMetaEntry?.max === "unbounded" ||
|
|
248
|
+
(typeof propMetaEntry?.max === "number" && propMetaEntry.max > 1);
|
|
249
|
+
const hintSaysArray = !!childTypeHint && childTypeHint.endsWith("[]");
|
|
250
|
+
const existing = parent.obj[propName];
|
|
251
|
+
const slotTaken = existing !== undefined;
|
|
252
|
+
|
|
253
|
+
if (metaSaysArray || hintSaysArray) {
|
|
254
|
+
if (Array.isArray(existing)) {
|
|
255
|
+
existing.push(value);
|
|
256
|
+
} else if (slotTaken) {
|
|
257
|
+
parent.obj[propName] = [existing, value];
|
|
258
|
+
} else {
|
|
259
|
+
parent.obj[propName] = [value];
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (slotTaken) {
|
|
264
|
+
// Schema said scalar but the wire repeated it. Promote to array rather
|
|
265
|
+
// than drop data.
|
|
266
|
+
if (Array.isArray(existing)) {
|
|
267
|
+
existing.push(value);
|
|
268
|
+
} else {
|
|
269
|
+
parent.obj[propName] = [existing, value];
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
parent.obj[propName] = value;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function stripArraySuffix(tsType: string): string {
|
|
277
|
+
return tsType.endsWith("[]") ? tsType.slice(0, -2) : tsType;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function tailMatches(stack: string[], path: string[]): boolean {
|
|
281
|
+
if (stack.length < path.length) return false;
|
|
282
|
+
const offset = stack.length - path.length;
|
|
283
|
+
for (let i = 0; i < path.length; i++) {
|
|
284
|
+
if (stack[offset + i] !== path[i]) return false;
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const UTF8_DECODER = new TextDecoder("utf-8");
|
|
290
|
+
|
|
291
|
+
function decodeUtf8(buf: Uint8Array): string {
|
|
292
|
+
return UTF8_DECODER.decode(buf);
|
|
293
|
+
}
|