@techspokes/typescript-wsdl-client 1.1.8 → 1.2.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.
Files changed (73) hide show
  1. package/README.md +17 -3
  2. package/dist/checkedPlanning.js +27 -4
  3. package/dist/cli.js +17 -1
  4. package/dist/client/generateClient.js +72 -16
  5. package/dist/client/generateOperations.js +30 -11
  6. package/dist/client/generateUtils.js +5 -1
  7. package/dist/compiler/canonicalGraph.d.ts +4 -15
  8. package/dist/compiler/canonicalGraph.js +1 -8
  9. package/dist/compiler/componentRecordProjection.d.ts +3 -0
  10. package/dist/compiler/componentRecordProjection.js +10 -0
  11. package/dist/compiler/declarationIdentity.d.ts +22 -0
  12. package/dist/compiler/declarationIdentity.js +9 -0
  13. package/dist/compiler/directCodec.d.ts +22 -2
  14. package/dist/compiler/directCodec.js +40 -7
  15. package/dist/compiler/directOperation.d.ts +26 -4
  16. package/dist/compiler/directOperation.js +59 -5
  17. package/dist/compiler/directSoap.js +24 -7
  18. package/dist/compiler/directSourceCapsule.js +59 -3
  19. package/dist/compiler/directStructure.js +6 -2
  20. package/dist/compiler/directStructurePlan.d.ts +10 -1
  21. package/dist/compiler/directStructurePlan.js +27 -7
  22. package/dist/compiler/directValues.d.ts +15 -3
  23. package/dist/compiler/directValues.js +20 -4
  24. package/dist/compiler/legacyDeclarations.d.ts +26 -0
  25. package/dist/compiler/legacyDeclarations.js +144 -0
  26. package/dist/compiler/projectionScalar.d.ts +18 -1
  27. package/dist/compiler/projectionScalar.js +47 -13
  28. package/dist/compiler/rpcStringValues.d.ts +30 -0
  29. package/dist/compiler/rpcStringValues.js +196 -0
  30. package/dist/compiler/rpcTypedValues.d.ts +48 -0
  31. package/dist/compiler/rpcTypedValues.js +190 -0
  32. package/dist/compiler/schemaCompiler.js +44 -109
  33. package/dist/compiler/semanticCatalog.d.ts +2 -2
  34. package/dist/gateway/generateComposedGateway.d.ts +16 -0
  35. package/dist/gateway/generateComposedGateway.js +65 -0
  36. package/dist/gateway/generateGateway.js +1 -0
  37. package/dist/gateway/generators.js +19 -4
  38. package/dist/gateway/helpers.d.ts +1 -0
  39. package/dist/gateway/helpers.js +15 -0
  40. package/dist/generation/componentShape.js +25 -1
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +2 -0
  43. package/dist/loader/orderedSyntax.d.ts +4 -0
  44. package/dist/loader/orderedSyntax.js +18 -4
  45. package/dist/multiServicePipeline.d.ts +41 -0
  46. package/dist/multiServicePipeline.js +287 -0
  47. package/dist/openapi/composeOpenAPI.d.ts +29 -0
  48. package/dist/openapi/composeOpenAPI.js +322 -0
  49. package/dist/pipeline.d.ts +1 -1
  50. package/dist/pipeline.js +2 -0
  51. package/docs/agent-skill.md +10 -1
  52. package/docs/api-reference.md +127 -9
  53. package/docs/checked-gateway.md +2 -0
  54. package/docs/checked-generation.md +11 -5
  55. package/docs/cli-reference.md +25 -5
  56. package/docs/concepts.md +4 -5
  57. package/docs/configuration.md +10 -3
  58. package/docs/corpus.md +1 -1
  59. package/docs/gateway-guide.md +104 -27
  60. package/docs/generated-code.md +12 -6
  61. package/docs/migration-playbook.md +4 -0
  62. package/docs/migration.md +124 -6
  63. package/docs/output-anatomy.md +20 -17
  64. package/docs/production.md +7 -5
  65. package/docs/start-here.md +12 -2
  66. package/docs/supported-patterns.md +17 -0
  67. package/docs/testing.md +2 -0
  68. package/docs/troubleshooting.md +3 -1
  69. package/llms.txt +9 -1
  70. package/package.json +2 -2
  71. package/src/runtime/clientStreamMethods.tpl.txt +156 -148
  72. package/src/runtime/operationsStreamHelper.tpl.txt +6 -6
  73. package/src/runtime/streamXml.ts +48 -20
@@ -0,0 +1,287 @@
1
+ import fs from "node:fs/promises";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { types } from "node:util";
6
+ import { fileURLToPath } from "node:url";
7
+ import { runGenerationPipeline } from "./pipeline.js";
8
+ import { composeOpenApiDocuments } from "./openapi/composeOpenAPI.js";
9
+ import { buildComposedGatewaySource } from "./gateway/generateComposedGateway.js";
10
+ import { deriveClientName } from "./util/tools.js";
11
+ import { slugName } from "./gateway/helpers.js";
12
+ const data = (value, label) => {
13
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value) || ![null, Object.prototype].includes(Object.getPrototypeOf(value)))
14
+ throw new TypeError(`${label} must be a data object`);
15
+ for (const property of Object.values(Object.getOwnPropertyDescriptors(value)))
16
+ if (!Object.hasOwn(property, "value"))
17
+ throw new TypeError(`${label} must not contain accessors`);
18
+ return value;
19
+ };
20
+ const text = (value, label) => {
21
+ if (typeof value !== "string" || !value.trim())
22
+ throw new TypeError(`${label} must be a nonempty string`);
23
+ return value;
24
+ };
25
+ const known = (value, allowed, label) => {
26
+ for (const key of Reflect.ownKeys(value))
27
+ if (typeof key !== "string" || !allowed.includes(key))
28
+ throw new TypeError(`Unknown ${label} option: ${String(key)}`);
29
+ };
30
+ const localPath = (value, label) => {
31
+ // @why Lexically different output paths can share a symlink/junction target; ownership checks must compare their filesystem destinations.
32
+ let ancestor = path.resolve(text(value, label));
33
+ const missing = [];
34
+ while (!existsSync(ancestor)) {
35
+ missing.unshift(path.basename(ancestor));
36
+ const parent = path.dirname(ancestor);
37
+ if (parent === ancestor)
38
+ throw new TypeError(`Cannot resolve output filesystem root for ${label}`);
39
+ ancestor = parent;
40
+ }
41
+ return path.join(realpathSync(ancestor), ...missing);
42
+ };
43
+ const canonical = (value) => process.platform === "win32" ? value.toLowerCase() : value;
44
+ const within = (directory, file) => {
45
+ const relative = path.relative(canonical(directory), canonical(file));
46
+ return relative === "" || relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
47
+ };
48
+ const prefix = (value) => {
49
+ const result = text(value, "generation.openapi.basePath");
50
+ if (!/^\/(?:[a-zA-Z0-9._~-]+\/)*[a-zA-Z0-9._~-]+$/.test(result) || result.split("/").some(segment => segment === "." || segment === ".."))
51
+ throw new TypeError("Each service needs an explicit static OpenAPI basePath without trailing slash");
52
+ return result;
53
+ };
54
+ function prepare(options) {
55
+ const input = data(options, "composition");
56
+ if (Object.keys(input).some(key => !["services", "openapi", "gateway"].includes(key)))
57
+ throw new TypeError("Unknown multi-service composition option");
58
+ if (!Array.isArray(options.services) || options.services.length === 0)
59
+ throw new TypeError("At least one independent service is required");
60
+ known(data(options.openapi, "aggregate OpenAPI"), ["outFile", "title", "version", "description", "servers"], "aggregate OpenAPI");
61
+ known(data(options.gateway, "aggregate gateway"), ["outDir", "imports", "openapiPath"], "aggregate gateway");
62
+ for (const name of ["title", "version", "description"])
63
+ if (options.openapi[name] !== undefined && typeof options.openapi[name] !== "string")
64
+ throw new TypeError(`openapi.${name} must be a string`);
65
+ if (options.openapi.servers !== undefined && (!Array.isArray(options.openapi.servers) || options.openapi.servers.some(server => typeof server !== "string" || !server)))
66
+ throw new TypeError("openapi.servers must contain server URL strings");
67
+ const outFile = localPath(options.openapi.outFile, "openapi.outFile");
68
+ if (path.extname(outFile).toLowerCase() !== ".json")
69
+ throw new TypeError("The aggregate OpenAPI output must be a JSON file");
70
+ const gatewayDir = localPath(options.gateway.outDir, "gateway.outDir");
71
+ if (options.gateway.imports !== undefined && !["js", "ts", "bare"].includes(options.gateway.imports))
72
+ throw new TypeError("Invalid aggregate import extension mode");
73
+ const openapiPath = options.gateway.openapiPath === false ? false : options.gateway.openapiPath ?? "/openapi.json";
74
+ if (openapiPath !== false && (!/^\/[^?#\\]*$/.test(openapiPath) || openapiPath.includes("//")))
75
+ throw new TypeError("Invalid OpenAPI discovery path");
76
+ const keys = new Set(), slugs = new Set(), decorators = new Set(), prefixes = new Set();
77
+ const outputs = [
78
+ { owner: "aggregate", role: "gateway", file: gatewayDir, directory: true },
79
+ { owner: "aggregate", role: "openapi", file: outFile, directory: false },
80
+ ];
81
+ const entries = options.services.map(entry => {
82
+ data(entry, "service entry");
83
+ if (Object.keys(entry).some(key => !["key", "generation"].includes(key)))
84
+ throw new TypeError("Unknown service entry option");
85
+ const key = text(entry.key, "service key");
86
+ if (!/^[a-z][a-z0-9-]*$/.test(key) || keys.has(key))
87
+ throw new TypeError("Unique lowercase service keys required");
88
+ keys.add(key);
89
+ const generation = data(entry.generation, `generation for ${key}`);
90
+ known(generation, ["model", "checkedComponents", "wsdl", "catalogOut", "clientOutDir", "compiler", "openapi", "gateway", "app", "test", "streamConfigFile", "streamConfig"], "service generation");
91
+ if (generation.model !== undefined && generation.model !== "legacy" || generation.checkedComponents !== undefined || generation.app !== undefined || generation.test !== undefined)
92
+ throw new TypeError("Multi-service generation currently composes legacy service routers; app/test/checked settings are not aggregate settings");
93
+ text(generation.wsdl, `${key}.wsdl`);
94
+ if (!generation.openapi || !generation.gateway)
95
+ throw new TypeError(`Service ${key} requires OpenAPI and gateway generation`);
96
+ data(generation.openapi, "service OpenAPI");
97
+ data(generation.gateway, "service gateway");
98
+ const basePath = prefix(generation.openapi.basePath);
99
+ if (prefixes.has(basePath))
100
+ throw new TypeError(`Duplicate service basePath ${basePath}`);
101
+ prefixes.add(basePath);
102
+ const clientDir = localPath(generation.clientOutDir, `${key}.clientOutDir`);
103
+ const catalogOut = localPath(generation.catalogOut, `${key}.catalogOut`);
104
+ const serviceOpenapi = localPath(generation.openapi.outFile, `${key}.openapi.outFile`);
105
+ const serviceGateway = localPath(generation.gateway.outDir, `${key}.gateway.outDir`);
106
+ if (!slugName(text(generation.gateway.versionSlug, `${key}.gateway.versionSlug`)))
107
+ throw new TypeError("A normalized gateway version slug is required");
108
+ const slug = slugName(text(generation.gateway.serviceSlug, `${key}.gateway.serviceSlug`));
109
+ if (!slug || slugs.has(slug))
110
+ throw new TypeError("Gateway service slugs must remain unique after normalization");
111
+ slugs.add(slug);
112
+ const decorator = generation.gateway.clientDecoratorName ?? `wsdl_${key.replaceAll("-", "_")}Client`;
113
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(decorator) || decorators.has(decorator))
114
+ throw new TypeError("Unique valid client decorators required");
115
+ decorators.add(decorator);
116
+ if (generation.gateway.emitPlugin === false || generation.gateway.stubHandlers === true || generation.gateway.emitRuntime === false)
117
+ throw new TypeError("Composition requires complete service plugins and runtime handlers");
118
+ for (const target of [clientDir, serviceGateway])
119
+ if (path.parse(target).root !== path.parse(gatewayDir).root)
120
+ throw new TypeError("Composed generated modules must share a filesystem root");
121
+ outputs.push({ owner: key, role: "client", file: clientDir, directory: true }, { owner: key, role: "gateway", file: serviceGateway, directory: true }, { owner: key, role: "catalog", file: catalogOut, directory: false }, { owner: key, role: "openapi", file: serviceOpenapi, directory: false });
122
+ return { key, generation: { ...generation, catalogOut, clientOutDir: clientDir,
123
+ openapi: { ...generation.openapi, basePath, outFile: serviceOpenapi },
124
+ gateway: { ...generation.gateway, outDir: serviceGateway, clientDecoratorName: decorator } } };
125
+ });
126
+ for (let a = 0; a < outputs.length; a++)
127
+ for (let b = a + 1; b < outputs.length; b++) {
128
+ const left = outputs[a], right = outputs[b];
129
+ if (canonical(left.file) === canonical(right.file))
130
+ throw new TypeError(`Conflicting output paths: ${left.role} and ${right.role}`);
131
+ const overlap = left.directory && within(left.file, right.file) || right.directory && within(right.file, left.file);
132
+ if (overlap && (left.owner !== right.owner || left.directory && right.directory))
133
+ throw new TypeError("Service output ownership must not overlap");
134
+ const file = left.directory ? right : left, directory = left.directory ? left : right;
135
+ if (overlap && !file.directory && directory.directory) {
136
+ const relative = path.relative(directory.file, file.file).replaceAll("\\", "/");
137
+ if (relative.endsWith(".ts") || /^(?:routes|schemas)(?:\/|$)/.test(relative))
138
+ throw new TypeError("Output file conflicts with generated service modules");
139
+ }
140
+ }
141
+ for (const entry of entries) {
142
+ const source = entry.generation.wsdl;
143
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source) && !source.startsWith("file:"))
144
+ continue;
145
+ const sourceFile = localPath(source.startsWith("file:") ? fileURLToPath(source) : source, "source WSDL");
146
+ if (outputs.some(output => output.directory ? within(output.file, sourceFile) : canonical(output.file) === canonical(sourceFile)))
147
+ throw new TypeError("Source WSDLs must remain outside generated output ownership");
148
+ }
149
+ return { entries, outFile, gatewayDir, openapiPath };
150
+ }
151
+ /** Publish the matched spec/plugin after all service generation and composition checks succeed.
152
+ * @constraints Uses only individually owned staging files; never removes a consumer output tree.
153
+ */
154
+ async function publish(files) {
155
+ const staged = [];
156
+ try {
157
+ for (const { file, content } of files) {
158
+ await fs.mkdir(path.dirname(file), { recursive: true });
159
+ let previous;
160
+ try {
161
+ previous = await fs.readFile(file);
162
+ }
163
+ catch (error) {
164
+ if (error.code !== "ENOENT")
165
+ throw error;
166
+ }
167
+ const temporary = file + ".stage-" + randomUUID();
168
+ staged.push({ file, temporary, previous, committed: false });
169
+ await fs.writeFile(temporary, content, { flag: "wx" });
170
+ }
171
+ for (const entry of staged) {
172
+ await fs.rename(entry.temporary, entry.file);
173
+ entry.committed = true;
174
+ }
175
+ }
176
+ catch (error) {
177
+ for (const entry of [...staged].reverse())
178
+ if (entry.committed) {
179
+ if (entry.previous === undefined)
180
+ await fs.unlink(entry.file);
181
+ else
182
+ await fs.writeFile(entry.file, entry.previous);
183
+ }
184
+ throw error;
185
+ }
186
+ finally {
187
+ for (const entry of staged)
188
+ await fs.unlink(entry.temporary).catch(error => { if (error.code !== "ENOENT")
189
+ throw error; });
190
+ }
191
+ }
192
+ /** Generate independent WSDL clients behind one typed gateway and discoverable API definition.
193
+ * @param options Explicit per-service pipelines and disjoint aggregate output paths.
194
+ * @returns Matched aggregate artifacts and each service's unmerged compiled catalog.
195
+ * @throws For configuration/output conflicts, source generation failures or invalid API composition.
196
+ * @sideEffects Generates each selected legacy service in caller order, then publishes the aggregate spec/plugin pair.
197
+ * @constraints Failure preserves previous aggregate spec/plugin files; earlier generated service dependencies are not rolled back.
198
+ */
199
+ export async function runMultiServicePipeline(options) {
200
+ const prepared = prepare(options);
201
+ const services = Object.create(null);
202
+ const documents = [];
203
+ const routers = [];
204
+ for (const { key, generation } of prepared.entries) {
205
+ const result = await runGenerationPipeline(generation);
206
+ const compiled = result.compiled;
207
+ const fileSlugs = new Set();
208
+ const expectedOperations = new Set(compiled.operations.map(operation => operation.name));
209
+ if (expectedOperations.size !== compiled.operations.length)
210
+ throw new TypeError(`Service ${key} has overloaded operation names that cannot own unique gateway routes`);
211
+ for (const operation of compiled.operations) {
212
+ const slug = slugName(operation.name);
213
+ if (!slug || fileSlugs.has(slug))
214
+ throw new TypeError(`Service ${key} has colliding generated operation filenames`);
215
+ fileSlugs.add(slug);
216
+ }
217
+ const described = [];
218
+ const document = data(result.openapiDoc, "generated OpenAPI");
219
+ for (const item of Object.values(data(document.paths, "generated paths"))) {
220
+ const pathItem = data(item, "generated path item");
221
+ for (const method of ["get", "put", "post", "delete", "options", "head", "patch", "trace"]) {
222
+ if (pathItem[method] !== undefined)
223
+ described.push(text(data(pathItem[method], "generated operation").operationId, "operationId"));
224
+ }
225
+ }
226
+ if (described.length !== expectedOperations.size || described.some(name => !expectedOperations.has(name)))
227
+ throw new TypeError(`Service ${key} loses operations through generated route collisions`);
228
+ const className = deriveClientName(compiled);
229
+ if (generation.gateway.clientClassName && generation.gateway.clientClassName !== className)
230
+ throw new TypeError("Configure compiler.clientName to keep client and gateway classes matched");
231
+ services[key] = { compiled, clientDir: generation.clientOutDir, gatewayDir: generation.gateway.outDir };
232
+ documents.push({ key, document: result.openapiDoc });
233
+ routers.push({ key, clientDir: generation.clientOutDir, gatewayDir: generation.gateway.outDir, clientClassName: className });
234
+ }
235
+ const openapiDoc = composeOpenApiDocuments(documents, options.openapi);
236
+ const { default: SwaggerParser } = await import("@apidevtools/swagger-parser");
237
+ const validator = new SwaggerParser();
238
+ await validator.validate(structuredClone(openapiDoc), { resolve: { external: false } });
239
+ if (prepared.openapiPath !== false && Object.hasOwn(openapiDoc.paths, prepared.openapiPath))
240
+ throw new TypeError("OpenAPI discovery path conflicts with a service route");
241
+ const pluginFile = path.join(prepared.gatewayDir, "plugin.ts");
242
+ const source = buildComposedGatewaySource(routers, { outDir: prepared.gatewayDir, openapiFile: prepared.outFile,
243
+ imports: options.gateway.imports, openapiPath: prepared.openapiPath });
244
+ await publish([{ file: prepared.outFile, content: JSON.stringify(openapiDoc, null, 2) + "\n" }, { file: pluginFile, content: source }]);
245
+ return { openapiDoc, services, pluginFile, openapiFile: prepared.outFile };
246
+ }
247
+ /** Load a composition config with paths resolved against its own directory.
248
+ * @throws For invalid JSON or missing required output/source paths when the pipeline validates it.
249
+ * @constraints Runtime client options and credentials are deliberately not generation configuration.
250
+ */
251
+ export async function loadMultiServiceConfig(file) {
252
+ const absolute = path.resolve(file), base = path.dirname(absolute);
253
+ const options = JSON.parse(await fs.readFile(absolute, "utf8"));
254
+ data(options, "composition");
255
+ data(options.openapi, "aggregate OpenAPI");
256
+ data(options.gateway, "aggregate gateway");
257
+ if (!Array.isArray(options.services))
258
+ throw new TypeError("services must be an array");
259
+ const resolve = (value) => value === undefined ? undefined : path.resolve(base, text(value, "config path"));
260
+ const source = (value) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : resolve(value);
261
+ options.openapi.outFile = resolve(options.openapi.outFile);
262
+ options.gateway.outDir = resolve(options.gateway.outDir);
263
+ for (const entry of options.services) {
264
+ data(entry, "service");
265
+ const generation = data(entry.generation, "generation");
266
+ generation.wsdl = source(text(generation.wsdl, "wsdl"));
267
+ generation.catalogOut = resolve(generation.catalogOut);
268
+ generation.clientOutDir = resolve(generation.clientOutDir);
269
+ generation.streamConfigFile = resolve(generation.streamConfigFile);
270
+ if (generation.openapi) {
271
+ generation.openapi.outFile = resolve(generation.openapi.outFile);
272
+ generation.openapi.tagsFile = resolve(generation.openapi.tagsFile);
273
+ generation.openapi.opsFile = resolve(generation.openapi.opsFile);
274
+ generation.openapi.securityConfigFile = resolve(generation.openapi.securityConfigFile);
275
+ }
276
+ if (generation.gateway)
277
+ generation.gateway.outDir = resolve(generation.gateway.outDir);
278
+ if (generation.streamConfig)
279
+ for (const shape of Object.values(generation.streamConfig.shapeCatalogs)) {
280
+ if (shape.catalogFile)
281
+ shape.catalogFile = resolve(shape.catalogFile);
282
+ if (shape.wsdlSource)
283
+ shape.wsdlSource = source(shape.wsdlSource);
284
+ }
285
+ }
286
+ return options;
287
+ }
@@ -0,0 +1,29 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
2
+ export interface JsonObject {
3
+ [key: string]: JsonValue;
4
+ }
5
+ export interface ComposedOpenApiDocument extends JsonObject {
6
+ openapi: string;
7
+ info: JsonObject;
8
+ paths: Record<string, JsonObject>;
9
+ components: Record<string, Record<string, JsonValue>>;
10
+ }
11
+ export interface OpenApiCompositionOptions {
12
+ title?: string;
13
+ version?: string;
14
+ description?: string;
15
+ servers?: readonly string[];
16
+ }
17
+ export interface OpenApiCompositionEntry {
18
+ key: string;
19
+ document: unknown;
20
+ }
21
+ /** Compose service-scoped OpenAPI contracts without changing SOAP client ownership.
22
+ * @param entries Self-contained OpenAPI 3.1 documents whose HTTP paths already contain their service prefixes.
23
+ * @param options Combined API metadata and default gateway servers; custom source servers remain operation-local.
24
+ * @returns A fresh deterministic document with qualified component names and operation identifiers.
25
+ * @throws For invalid JSON, duplicate service keys/paths/operation IDs, unresolved local references or unsupported relative references/schema resources.
26
+ * @constraints Only reference-bearing OpenAPI/JSON Schema positions are rewritten; examples, literal values and extensions remain data.
27
+ * @see https://spec.openapis.org/oas/v3.1.0.html Reference, Link, Security Requirement and Schema objects govern distinct reference positions.
28
+ */
29
+ export declare function composeOpenApiDocuments(entries: readonly OpenApiCompositionEntry[], options?: OpenApiCompositionOptions): ComposedOpenApiDocument;
@@ -0,0 +1,322 @@
1
+ import { types } from "node:util";
2
+ const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
3
+ const categories = {
4
+ schemas: "schema", responses: "response", parameters: "parameter", examples: "example",
5
+ requestBodies: "requestBody", headers: "header", securitySchemes: "securityScheme", links: "link",
6
+ callbacks: "callback", pathItems: "pathItem",
7
+ };
8
+ const dict = () => Object.create(null);
9
+ const record = (value, context) => {
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ throw new TypeError(`${context} must be an object`);
12
+ return value;
13
+ };
14
+ const items = (value, context) => {
15
+ if (!Array.isArray(value))
16
+ throw new TypeError(`${context} must be an array`);
17
+ return value;
18
+ };
19
+ const pointerToken = (value) => value.replaceAll("~", "~0").replaceAll("/", "~1");
20
+ /** Copy JSON data without invoking accessors or sharing mutable source objects.
21
+ * @throws For non-JSON values, accessors, proxies, sparse arrays or cycles.
22
+ */
23
+ function copyJson(value, active = new Set()) {
24
+ if (value === null || typeof value === "string" || typeof value === "boolean")
25
+ return value;
26
+ if (typeof value === "number" && Number.isFinite(value))
27
+ return value;
28
+ if (!value || typeof value !== "object" || types.isProxy(value) || active.has(value))
29
+ throw new TypeError("Actual acyclic JSON data required");
30
+ if (!Array.isArray(value) && ![null, Object.prototype].includes(Object.getPrototypeOf(value)))
31
+ throw new TypeError("Plain JSON objects required");
32
+ active.add(value);
33
+ try {
34
+ if (Array.isArray(value)) {
35
+ if (Reflect.ownKeys(value).length !== value.length + 1)
36
+ throw new TypeError("Dense JSON arrays required");
37
+ return Array.from({ length: value.length }, (_, index) => {
38
+ const property = Object.getOwnPropertyDescriptor(value, String(index));
39
+ if (!property || !Object.hasOwn(property, "value"))
40
+ throw new TypeError("JSON array data properties required");
41
+ return copyJson(property.value, active);
42
+ });
43
+ }
44
+ const result = dict();
45
+ for (const key of Reflect.ownKeys(value)) {
46
+ const property = Object.getOwnPropertyDescriptor(value, key);
47
+ if (typeof key !== "string" || !property.enumerable || !Object.hasOwn(property, "value"))
48
+ throw new TypeError("JSON object data properties required");
49
+ result[key] = copyJson(property.value, active);
50
+ }
51
+ return result;
52
+ }
53
+ finally {
54
+ active.delete(value);
55
+ }
56
+ }
57
+ function stable(value) {
58
+ if (Array.isArray(value))
59
+ return value.map(stable);
60
+ if (!value || typeof value !== "object")
61
+ return value;
62
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
63
+ }
64
+ /** Compose service-scoped OpenAPI contracts without changing SOAP client ownership.
65
+ * @param entries Self-contained OpenAPI 3.1 documents whose HTTP paths already contain their service prefixes.
66
+ * @param options Combined API metadata and default gateway servers; custom source servers remain operation-local.
67
+ * @returns A fresh deterministic document with qualified component names and operation identifiers.
68
+ * @throws For invalid JSON, duplicate service keys/paths/operation IDs, unresolved local references or unsupported relative references/schema resources.
69
+ * @constraints Only reference-bearing OpenAPI/JSON Schema positions are rewritten; examples, literal values and extensions remain data.
70
+ * @see https://spec.openapis.org/oas/v3.1.0.html Reference, Link, Security Requirement and Schema objects govern distinct reference positions.
71
+ */
72
+ export function composeOpenApiDocuments(entries, options = {}) {
73
+ if (!Array.isArray(entries) || entries.length === 0)
74
+ throw new TypeError("At least one service document is required");
75
+ const input = items(copyJson(entries), "services");
76
+ const keys = new Set();
77
+ const paths = dict();
78
+ const components = dict();
79
+ const webhooks = dict();
80
+ const tags = dict();
81
+ const services = dict();
82
+ const rootServers = options.servers?.map(url => ({ url })) ?? [{ url: "/" }];
83
+ let dialect;
84
+ for (const entry of input.map(value => record(value, "service")).sort((left, right) => String(left.key).localeCompare(String(right.key)))) {
85
+ const key = entry.key;
86
+ if (typeof key !== "string" || !/^[a-z][a-z0-9-]*$/.test(key) || keys.has(key))
87
+ throw new TypeError("Unique lowercase service keys required");
88
+ keys.add(key);
89
+ const source = record(entry.document, `service ${key}`);
90
+ if (typeof source.openapi !== "string" || !/^3\.1\.\d+$/.test(source.openapi))
91
+ throw new TypeError("OpenAPI 3.1 documents required");
92
+ record(source.info, "OpenAPI info");
93
+ const sourcePaths = record(source.paths, "OpenAPI paths");
94
+ const sourceComponents = source.components === undefined ? dict() : record(source.components, "OpenAPI components");
95
+ const sourceDialect = source.jsonSchemaDialect ?? "https://spec.openapis.org/oas/3.1/dialect/base";
96
+ if (typeof sourceDialect !== "string" || dialect !== undefined && dialect !== sourceDialect)
97
+ throw new TypeError("Source schema dialects must agree");
98
+ dialect = sourceDialect;
99
+ const qualified = (name) => `${key}__${name}`;
100
+ const operationIds = new Map();
101
+ const operationNodes = new Set();
102
+ const sourceRootServers = source.servers === undefined ? undefined : items(source.servers, "servers");
103
+ const defaultSourceServer = JSON.stringify(sourceRootServers) === '[{"url":"/"}]';
104
+ const effectiveServers = options.servers && defaultSourceServer ? undefined : sourceRootServers;
105
+ const defaultSecurity = source.security;
106
+ function resolve(tokens, reference) {
107
+ let target = source;
108
+ for (const token of tokens) {
109
+ if (!target || typeof target !== "object" || !Object.hasOwn(target, token))
110
+ throw new TypeError(`Unresolved local reference ${reference} in ${key}`);
111
+ target = target[token];
112
+ }
113
+ return target;
114
+ }
115
+ function reference(value, operation = false) {
116
+ if (typeof value !== "string" || value.length === 0)
117
+ throw new TypeError("Reference must be a nonempty string");
118
+ if (!value.startsWith("#")) {
119
+ try {
120
+ new URL(value);
121
+ }
122
+ catch {
123
+ throw new TypeError(`Relative external references need resolution before composition: ${value}`);
124
+ }
125
+ return value;
126
+ }
127
+ let fragment;
128
+ try {
129
+ fragment = decodeURIComponent(value.slice(1));
130
+ }
131
+ catch {
132
+ throw new TypeError(`Invalid reference ${value}`);
133
+ }
134
+ if (!fragment.startsWith("/") || /~(?![01])/u.test(fragment))
135
+ throw new TypeError(`Unsupported local reference ${value}`);
136
+ const tokens = fragment.slice(1).split("/").map(token => token.replaceAll("~1", "/").replaceAll("~0", "~"));
137
+ const target = resolve(tokens, value);
138
+ if (operation && (!target || typeof target !== "object" || Array.isArray(target) || !operationNodes.has(target)))
139
+ throw new TypeError(`operationRef does not resolve to an operation: ${value}`);
140
+ if (tokens[0] === "components" && tokens.length >= 3)
141
+ tokens[2] = qualified(tokens[2]);
142
+ else if (tokens[0] === "webhooks" && tokens.length >= 2)
143
+ tokens[1] = qualified(tokens[1]);
144
+ else if (tokens[0] !== "paths")
145
+ throw new TypeError(`Reference target cannot be retained during composition: ${value}`);
146
+ return "#/" + tokens.map(token => encodeURIComponent(pointerToken(token))).join("/");
147
+ }
148
+ function security(value) {
149
+ return items(value, "security").map(requirement => Object.fromEntries(Object.entries(record(requirement, "security requirement")).map(([name, scopes]) => {
150
+ if (!Object.hasOwn(record(sourceComponents.securitySchemes, "securitySchemes"), name))
151
+ throw new TypeError(`Missing security scheme ${name}`);
152
+ if (!items(scopes, "security scopes").every(scope => typeof scope === "string"))
153
+ throw new TypeError("Security scopes must be strings");
154
+ return [qualified(name), scopes];
155
+ })));
156
+ }
157
+ function map(value, kind, scan, extensions = false) {
158
+ return Object.fromEntries(Object.entries(record(value, `${kind} map`)).map(([name, child]) => [name,
159
+ extensions && name.startsWith("x-") ? child : walk(child, kind, scan)]));
160
+ }
161
+ function walk(value, kind, scan, inheritedServers = effectiveServers) {
162
+ if (kind === "data")
163
+ return value;
164
+ if (kind === "schema" && typeof value === "boolean")
165
+ return value;
166
+ const node = record(value, kind);
167
+ const out = { ...node };
168
+ if (kind === "operation" && scan) {
169
+ operationNodes.add(node);
170
+ if (node.operationId !== undefined) {
171
+ if (typeof node.operationId !== "string" || !node.operationId || operationIds.has(node.operationId))
172
+ throw new TypeError(`Duplicate or invalid operationId in ${key}`);
173
+ operationIds.set(node.operationId, node);
174
+ }
175
+ }
176
+ if (Object.hasOwn(node, "$ref") && !scan)
177
+ out.$ref = reference(node.$ref);
178
+ const array = (name, childKind) => { if (node[name] !== undefined)
179
+ out[name] = items(node[name], name).map(child => walk(child, childKind, scan)); };
180
+ const child = (name, childKind) => { if (node[name] !== undefined)
181
+ out[name] = walk(node[name], childKind, scan); };
182
+ const keyed = (name, childKind, extensions = false) => { if (node[name] !== undefined)
183
+ out[name] = map(node[name], childKind, scan, extensions); };
184
+ if (kind === "schema") {
185
+ if (["$id", "$anchor", "$dynamicAnchor", "$vocabulary"].some(name => Object.hasOwn(node, name)))
186
+ throw new TypeError("Resolve schema resources/anchors before OpenAPI composition");
187
+ if (node.$dynamicRef !== undefined && !scan)
188
+ out.$dynamicRef = reference(node.$dynamicRef);
189
+ for (const name of ["properties", "patternProperties", "dependentSchemas", "$defs", "definitions"])
190
+ keyed(name, "schema");
191
+ for (const name of ["allOf", "anyOf", "oneOf", "prefixItems"])
192
+ array(name, "schema");
193
+ for (const name of ["items", "contains", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "not", "if", "then", "else", "additionalItems"])
194
+ child(name, "schema");
195
+ if (node.discriminator !== undefined && !scan) {
196
+ const discriminator = record(node.discriminator, "discriminator");
197
+ const mapping = discriminator.mapping;
198
+ if (mapping !== undefined)
199
+ out.discriminator = { ...discriminator, mapping: Object.fromEntries(Object.entries(record(mapping, "discriminator mapping")).map(([name, target]) => {
200
+ if (typeof target !== "string")
201
+ throw new TypeError("Discriminator targets must be strings");
202
+ const schemas = record(sourceComponents.schemas, "schemas");
203
+ return [name, Object.hasOwn(schemas, target) ? qualified(target) : reference(target)];
204
+ })) };
205
+ }
206
+ }
207
+ else if (kind === "pathItem") {
208
+ array("parameters", "parameter");
209
+ const servers = node.servers === undefined ? inheritedServers : items(node.servers, "path servers");
210
+ for (const method of methods)
211
+ if (node[method] !== undefined)
212
+ out[method] = walk(node[method], "operation", scan, servers);
213
+ }
214
+ else if (kind === "operation") {
215
+ array("parameters", "parameter");
216
+ child("requestBody", "requestBody");
217
+ keyed("responses", "response", true);
218
+ keyed("callbacks", "callback");
219
+ if (!scan) {
220
+ if (node.operationId !== undefined)
221
+ out.operationId = `${key}.${node.operationId}`;
222
+ if (node.security !== undefined || defaultSecurity !== undefined)
223
+ out.security = security(node.security ?? defaultSecurity);
224
+ if (node.servers === undefined && inheritedServers !== undefined && JSON.stringify(inheritedServers) !== JSON.stringify(rootServers))
225
+ out.servers = inheritedServers;
226
+ if (node.tags !== undefined)
227
+ out.tags = items(node.tags, "tags").map(tag => { if (typeof tag !== "string")
228
+ throw new TypeError("Tags must be strings"); return `${key}.${tag}`; });
229
+ }
230
+ }
231
+ else if (kind === "parameter" || kind === "header" || kind === "mediaType") {
232
+ child("schema", "schema");
233
+ keyed("examples", "example");
234
+ keyed("content", "mediaType");
235
+ keyed("encoding", "encoding");
236
+ if (kind === "mediaType" && node["x-wsdl-tsc-stream"] !== undefined) {
237
+ // @why Generated stream consumers discover record schemas here; unrelated extension payloads may contain literal $ref data.
238
+ const stream = record(node["x-wsdl-tsc-stream"], "stream metadata");
239
+ out["x-wsdl-tsc-stream"] = { ...stream,
240
+ ...(stream.itemSchema === undefined ? {} : { itemSchema: walk(stream.itemSchema, "schema", scan) }) };
241
+ }
242
+ }
243
+ else if (kind === "encoding")
244
+ keyed("headers", "header");
245
+ else if (kind === "requestBody")
246
+ keyed("content", "mediaType");
247
+ else if (kind === "response") {
248
+ keyed("headers", "header");
249
+ keyed("content", "mediaType");
250
+ keyed("links", "link");
251
+ }
252
+ else if (kind === "callback") {
253
+ for (const [expression, pathItem] of Object.entries(node))
254
+ if (expression !== "$ref" && !expression.startsWith("x-"))
255
+ out[expression] = walk(pathItem, "pathItem", scan);
256
+ }
257
+ else if (kind === "link" && !scan) {
258
+ if (node.operationId !== undefined && node.operationRef !== undefined)
259
+ throw new TypeError("A link cannot set both operationId and operationRef");
260
+ if (node.operationId !== undefined) {
261
+ if (typeof node.operationId !== "string" || !operationIds.has(node.operationId))
262
+ throw new TypeError(`Unresolved link operationId ${String(node.operationId)}`);
263
+ out.operationId = `${key}.${node.operationId}`;
264
+ }
265
+ if (node.operationRef !== undefined)
266
+ out.operationRef = reference(node.operationRef, true);
267
+ }
268
+ return out;
269
+ }
270
+ for (const [route, value] of Object.entries(sourcePaths))
271
+ if (!route.startsWith("x-"))
272
+ walk(value, "pathItem", true);
273
+ if (source.webhooks !== undefined)
274
+ map(source.webhooks, "pathItem", true);
275
+ for (const [category, values] of Object.entries(sourceComponents)) {
276
+ if (category.startsWith("x-"))
277
+ continue;
278
+ if (!Object.hasOwn(categories, category))
279
+ throw new TypeError(`Unsupported OpenAPI component category ${category}`);
280
+ map(values, categories[category], true);
281
+ }
282
+ for (const [route, value] of Object.entries(sourcePaths)) {
283
+ if (route.startsWith("x-"))
284
+ continue;
285
+ if (!route.startsWith("/") || Object.hasOwn(paths, route))
286
+ throw new TypeError(`Duplicate or invalid final path ${route}`);
287
+ paths[route] = record(walk(value, "pathItem", false), "path item");
288
+ }
289
+ if (source.webhooks !== undefined)
290
+ for (const [name, value] of Object.entries(record(source.webhooks, "webhooks")))
291
+ webhooks[qualified(name)] = walk(value, "pathItem", false);
292
+ for (const [category, values] of Object.entries(sourceComponents)) {
293
+ if (category.startsWith("x-"))
294
+ continue;
295
+ const destination = components[category] ??= dict();
296
+ for (const [name, value] of Object.entries(record(values, "components")))
297
+ destination[qualified(name)] = walk(value, categories[category], false);
298
+ }
299
+ if (source.tags !== undefined)
300
+ for (const value of items(source.tags, "tags")) {
301
+ const tag = record(value, "tag");
302
+ if (typeof tag.name !== "string")
303
+ throw new TypeError("Tag name must be a string");
304
+ tags[`${key}.${tag.name}`] = { ...tag, name: `${key}.${tag.name}` };
305
+ }
306
+ services[key] = { info: source.info,
307
+ extensions: Object.fromEntries(Object.entries(source).filter(([name]) => name.startsWith("x-"))),
308
+ componentExtensions: Object.fromEntries(Object.entries(sourceComponents).filter(([name]) => name.startsWith("x-"))),
309
+ pathExtensions: Object.fromEntries(Object.entries(sourcePaths).filter(([name]) => name.startsWith("x-"))) };
310
+ }
311
+ const info = { title: options.title ?? "Composed SOAP API", version: options.version ?? "0.0.0" };
312
+ if (options.description !== undefined)
313
+ info.description = options.description;
314
+ const result = { openapi: "3.1.0", info, servers: rootServers, paths, components, "x-wsdl-tsc-services": services };
315
+ if (Object.keys(webhooks).length)
316
+ result.webhooks = webhooks;
317
+ if (Object.keys(tags).length)
318
+ result.tags = Object.keys(tags).sort().map(name => tags[name]);
319
+ if (dialect !== "https://spec.openapis.org/oas/3.1/dialect/base")
320
+ result.jsonSchemaDialect = dialect;
321
+ return stable(result);
322
+ }
@@ -53,7 +53,7 @@ export interface PipelineOptions {
53
53
  /**
54
54
  * Path to a stream configuration JSON file. Loaded and parsed once before
55
55
  * compilation; the parsed value takes precedence over `streamConfig` when
56
- * both are set. Leave undefined to preserve byte-for-byte buffered output.
56
+ * both are set. Unlisted operations retain their buffered API selection.
57
57
  */
58
58
  streamConfigFile?: string;
59
59
  /**