diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 026f3e6..21b1e81 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -283,6 +283,10 @@ Examples: ### Release workflow +Prepare the complete release candidate, including `docs/releases/vX.Y.Z.md`, package versions, changelog, and root documentation index, before pushing the release PR. Complete the PR template checklist. Commit the preflight-validated tree, push the PR, and require Node 24 and Node 26 checks on its exact head before merging. Verify that merged main has the same tree and successful CI, then tag that merged commit. A different merged tree requires a newly validated candidate before tagging. + +Agent delivery ends after the tag-triggered workflow creates and verifies the draft and its artifacts, followed by task-owned local and remote cleanup. Agents must never access GitHub through a browser, publish releases, or dispatch package publication. The maintainer signs in through their own browser and clicks Publish; that event triggers npm publication. Preserve unrelated work, contributor-owned branches, release tags, and abandonment markers during cleanup. + 1. Read current package version from `package.json` and the latest dated release section in `CHANGELOG.md`. 2. Determine the target semver bump based on `Unreleased` changes or the user's explicit target. 3. For patch releases, verify the target is the next patch after the latest released version. @@ -298,7 +302,7 @@ Examples: 13. Confirm `dist/assets/typescript-wsdl-client-agent-skill-v.zip` and `dist/assets/SHA256SUMS` exist after preflight. 14. If preflight fails or a separate command changes tracked release files afterward, finish the fixes and rerun preflight on the new final candidate. Preflight itself fails if it changes tracked content. 15. After preflight passes, commit the exact validated release tree without changing tracked files. -16. Tag and push the release commit without rerunning preflight, CI, package validation, or skill packaging. +16. Push and merge the release PR after its required checks, then tag the validated merged main commit without rerunning local preflight, CI, package validation, or skill packaging for an unchanged tree. ### Abandoned release candidates @@ -310,7 +314,7 @@ The draft and package workflows must call the shared release-state helper before Drafting, abandonment, and package delivery share a non-cancelling per-tag concurrency group. Preserve that serialization so a marker cannot race package publication for the same candidate. -The marker cannot disable GitHub's Publish button while a draft exists. The supported normal path is to review and publish an unmarked draft through GitHub's Release page; the guarded package workflow then delivers npmjs with provenance. Abandonment retires the draft only after marker verification. Manual package dispatch is recovery-only and enforces the same published-release and marker checks. +The marker cannot disable GitHub's Publish button while a draft exists. The maintainer reviews and publishes an unmarked draft through GitHub's Release page after agent delivery has ended; the guarded package workflow then delivers npmjs with provenance. Abandonment retires the draft only after marker verification. Manual package dispatch is maintainer-operated recovery only and enforces the same published-release and marker checks. Do not add a workflow that publishes the draft and assumes its normal `GITHUB_TOKEN` will trigger the package workflow. GitHub suppresses that recursive workflow event. The supported UI publication path produces the external `release: published` event, while manual package dispatch remains the explicit recovery path. @@ -400,7 +404,8 @@ Keep `CHANGELOG.md` as the canonical version history. Write release notes as a c - Review the complete uncommitted release diff. - Run `npm run release:preflight -- vX.Y.Z` once on the final uncommitted release tree. - Commit the exact validated tree without further tracked-file changes. -- Tag and push without rerunning release validation. +- Push and merge the complete release PR, verify main's tree, then tag the merged commit without repeating unchanged local validation. +- Verify the draft workflow and artifacts, finish task-owned cleanup, and stop for maintainer publication. - Use the skill ZIP produced by release preflight; do not run a separate skill package step unless preflight is not being used. ### Editing code that affects CLI, OpenAPI, or gateway diff --git a/AGENTS.md b/AGENTS.md index 8a39fe8..70dd5d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ This is a TypeScript code generator that transforms WSDL/XSD SOAP service defini - Post-release patch work uses the next patch version in commit titles even before `package.json` is bumped. - A commit title target such as `Version: 0.30.3` does not mean the repository is ready for tag `v0.30.3`. - Every release commit must include the matching `docs/releases/vX.Y.Z.md` release notes file. +- Prepare release notes and metadata before pushing the release PR; tag the validated merged state of main. +- Agents stop after draft/artifact verification and task-owned cleanup; the maintainer publishes manually. +- Agents never access GitHub through a browser or publish releases or packages. - Node.js >= 24.0.0, ESM-only (`type: "module"`), strict TypeScript. - GitHub CI must test Node 24 as the supported floor and Node 26 as the current line. - GitHub push and PR CI is a fast hosted signal; run `npm run release:preflight -- vX.Y.Z` once on the final uncommitted release tree before committing and tagging. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df7528..62b35b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,15 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## [Unreleased] +## [1.1.2] - 2026-09-25 + +- fix(client): normalize singleton SOAP responses for repeated elements +- docs(release): assign draft preparation and cleanup to agents and publication to the maintainer + ## [1.1.1] - 2026-09-25 +This candidate was abandoned before publication because singleton SOAP responses violated generated array contracts. + - fix(compiler): preserve wrapping sequence occurrence bounds on element particles - chore(deps): refresh soap and Vitest patch floors and generated app soap pins diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70a8e56..9163ae2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -209,12 +209,16 @@ Run `npm run maint:deps` when preparing a release so root dependency minimums an Every release commit must include `docs/releases/vX.Y.Z.md`. Run `npm run release:preflight -- vX.Y.Z` once on the final uncommitted release tree and confirm the release ZIP exists under `dist/assets/`. +Include release notes, versions, changelog, and the root documentation link before pushing the release PR. After merging, verify that main's tree matches the preflight-validated tree before tagging the merged commit. Follow the [release delivery procedure](docs/releases/README.md#candidate-publication). + Set the release version before preflight. Preflight verifies release metadata, writes only ignored build and temporary outputs, and fails if it changes tracked content. After preflight passes, commit the exact validated tree without further tracked-file changes. Tag and push that commit without rerunning preflight, full CI, package validation, or skill packaging. If another command changes tracked files, complete the fixes and rerun preflight on the new final candidate. Pushing the matching `vX.Y.Z` tag creates or updates a GitHub draft release from the release notes file after CI passes. The draft release workflow strips the release-note H1 for GitHub display, packages and uploads the agent skill ZIP, and refuses to mutate a published non-draft release. +The agent verifies the draft and artifacts, cleans up task-owned local and remote branches and temporary outputs, and stops. The maintainer publishes through their own browser. Agents never access GitHub through a browser or publish releases or packages. + When an IDE inspection tool is available, run it on touched Markdown files and generator files that embed generated source. Without IDE inspections, verify relative Markdown links target concrete files or headings and keep TypeScript fenced examples syntactically valid. ## Adding Test Fixtures diff --git a/README.md b/README.md index 9966078..833be43 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ See [CLI Reference](docs/cli-reference.md) for all flags and examples. | [Production Guide](docs/production.md) | CI/CD, validation, logging | | [Troubleshooting](docs/troubleshooting.md) | Common issues and debugging | | [Release Notes](docs/releases/README.md) | Per-version GitHub release notes used by the draft release workflow | +| [v1.1.2 Release Notes](docs/releases/v1.1.2.md) | Sequence occurrence bounds and singleton SOAP response normalization | | [v1.1.1 Release Notes](docs/releases/v1.1.1.md) | Wrapping sequence occurrence bounds and generated array contracts | | [v1.1.0 Release Notes](docs/releases/v1.1.0.md) | Automatic generated TypeScript preambles and consumer lint guidance | diff --git a/docs/releases/README.md b/docs/releases/README.md index b45d5ca..6677486 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -59,11 +59,19 @@ When an abandoned major or minor candidate contains the complete launch narrativ The supported release path keeps final-form tags immutable: -1. Run release preflight on the final uncommitted tree. -2. Commit the exact validated tree and create `vX.Y.Z`. -3. Push the branch and tag so the draft workflow creates the release and skill asset. -4. Review and publish the unmarked draft through GitHub's Release page. -5. Let the guarded package workflow publish to npmjs with provenance. +1. Prepare versions, changelog, release notes, and the documentation index before pushing the release PR. +2. Run release preflight on the final uncommitted tree and commit that exact validated tree. +3. Push the PR and require Node 24 and Node 26 checks on its final head before merging. +4. Verify the merged main tree matches the validated tree and require CI on the merged commit. +5. Tag the validated merged main commit and push the tag to start the draft workflow. +6. Verify the draft, release notes, uploaded skill ZIP, checksum manifest, and artifact verification results. +7. Clean up task-owned merged branches and temporary outputs, then hand the draft to the maintainer. +8. The maintainer reviews the draft in their own browser and clicks Publish. +9. The guarded package workflow publishes to npmjs with provenance. + +The agent stops after draft verification and cleanup. Agents must never access GitHub through a browser, publish a release, or dispatch package publication. Use authenticated git and GitHub CLI commands for preparation and verification. Preserve unrelated work, contributor-owned branches, immutable release tags, and abandonment markers during cleanup. + +If the merged tree differs from the preflight-validated tree, stop before tagging and prepare a newly validated candidate. Do not rerun preflight for an unchanged validated tree. Manual package dispatch is a recovery path after the GitHub Release is published. It enforces the same tag, marker, reachability, draft, and prerelease checks. diff --git a/docs/releases/v1.1.1.md b/docs/releases/v1.1.1.md index e67d70c..622f33d 100644 --- a/docs/releases/v1.1.1.md +++ b/docs/releases/v1.1.1.md @@ -39,4 +39,6 @@ This patch preserves the previous behavior across `xs:choice`, `xs:all`, zero-ma Release tag: `v1.1.1`. +This candidate was abandoned before publication. Singleton SOAP responses could remain scalar and fail gateway serialization despite generated array contracts. The immutable tag and abandonment marker retain its release identity. + Fixes [issue #141](https://github.com/TechSpokes/typescript-wsdl-client/issues/141) through [PR #145](https://github.com/TechSpokes/typescript-wsdl-client/pull/145). Thanks to [BCsabaEngine](https://github.com/BCsabaEngine) for the original diagnosis and contribution. diff --git a/docs/releases/v1.1.2.md b/docs/releases/v1.1.2.md new file mode 100644 index 0000000..445df8c --- /dev/null +++ b/docs/releases/v1.1.2.md @@ -0,0 +1,42 @@ +# TypeScript WSDL Client v1.1.2 + +## Consistent arrays from wrapping sequences + +This patch corrects generated array contracts and singleton SOAP response conversion for wrapping sequences. See the [project overview](../../README.md) for the generation pipeline. + +## What This Improves + +An element inside a repeated `xs:sequence` now receives the sequence's occurrence bounds in the compiled catalog and generated types. A single returned address becomes a one-element array, and multiple addresses retain their order. Generated REST gateways can serialize both responses successfully. + +The generated client uses existing catalog metadata to normalize repeated properties, so direct client consumers receive the same correction as gateways. Optional properties remain absent when omitted, and existing null handling is preserved. + +## Highlights + +- Propagate occurrence bounds through uninterrupted sequences, including nested sequences. +- Normalize singleton primitive and complex responses to their declared array shape. +- Preserve inherited and aliased type metadata during response conversion. +- Verify real SOAP responses with REST wrapper flattening enabled and disabled. +- Exercise the correction through an installed package and its generated client. + +## Upgrade Notes + +Update the package and regenerate clients, OpenAPI specifications, and gateways. Review affected scalar properties that become arrays and required properties that become optional. Corrected array metadata can also activate existing REST wrapper flattening, changing an object wrapper into an array. + +This release retains previous sequence-boundary handling for `xs:choice`, `xs:all`, zero-maximum sequences, disabled elements, and wildcards. It adds no finite-array-length or sequence-order validation and no CLI options or catalog fields. The generated metadata interface adds an optional `RepeatedElements` map; custom metadata without that map retains its previous behavior. + +Version 1.1.1 was abandoned before publication. This release includes its sequence compiler correction and dependency maintenance, together with the runtime correction discovered during final review. + +## Validation + +- Real SOAP transport tests cover singleton and repeated responses and optional request omission. +- Generated client and gateway tests cover both REST wrapper representations. +- Full CI, conformance, and pipeline smoke validation passed. +- Installed package generation, TypeScript contracts, and SOAP response conversion passed. +- NPM package contents and the agent skill artifact were validated. +- Release preflight passed against the target tag. + +## Notes + +Release tag: `v1.1.2`. + +The sequence compiler correction originated in [PR #145](https://github.com/TechSpokes/typescript-wsdl-client/pull/145) for [issue #141](https://github.com/TechSpokes/typescript-wsdl-client/issues/141). Thanks to [BCsabaEngine](https://github.com/BCsabaEngine) for the original diagnosis and contribution. diff --git a/docs/supported-patterns.md b/docs/supported-patterns.md index 2130ae9..ed2b307 100644 --- a/docs/supported-patterns.md +++ b/docs/supported-patterns.md @@ -35,6 +35,8 @@ These patterns are handled end-to-end: WSDL parsing, TypeScript type generation, Wrapping sequence bounds propagate through uninterrupted sequence ancestry to element array types and optionality. Traversal through `xs:choice`, `xs:all`, or a sequence with `maxOccurs="0"` retains the previous flattened behavior; wildcard and disabled-element handling are unchanged. This does not enforce sequence ordering or finite array lengths at runtime. +Generated clients normalize present, non-null singleton responses into arrays for repeated element properties. Real SOAP transport regressions cover singleton and multiple items, omitted optional request fields, and gateway serialization with wrapper flattening enabled and disabled. Regenerate clients to receive this runtime correction; omitted optional response properties remain absent. + - Complex types with ``, ``, and `` compositors, including recursive nesting - Simple content with attributes using the `$value` pattern to preserve text content alongside attribute properties - Named simple type restrictions and enumerations emitted as TypeScript aliases and OpenAPI scalar schemas diff --git a/examples/generated-output/client/client.ts b/examples/generated-output/client/client.ts index 888037a..44d657a 100644 --- a/examples/generated-output/client/client.ts +++ b/examples/generated-output/client/client.ts @@ -43,6 +43,7 @@ export type WeatherResponse> * @property dataTypes - Metadata for data types used in serialization/deserialization. * @property dataTypes.Attributes - Maps type names to lists of property names that should be treated as XML attributes. * @property dataTypes.ChildrenTypes - Maps type names to their child element types for recursive processing. + * @property dataTypes.RepeatedElements - Identifies child properties that must retain their array shape for singleton responses. * * * @note Have fun with the generated client! If TechSpokes made your day (or a week), @@ -324,6 +325,7 @@ export class Weather { // Get child type mapping for recursive processing with correct types const childrenTypes: Readonly> = (typeName && this.dataTypes?.ChildrenTypes?.[typeName]) || {}; + const repeatedElements: readonly string[] = (typeName && this.dataTypes?.RepeatedElements?.[typeName]) || []; const result: any = {}; // Preserve text content for mixed XML elements @@ -347,9 +349,11 @@ export class Weather { } // Recursively convert child elements with their specific type info const childType: string | undefined = childrenTypes[k]; - result[k] = Array.isArray(v) - ? v.map(node => this.fromSoapResult(node, childType)) - : this.fromSoapResult(v, childType); + // node-soap can return a scalar when repetition is declared on a wrapping sequence (#141). + const value = v != null && !Array.isArray(v) && repeatedElements.includes(k) ? [v] : v; + result[k] = Array.isArray(value) + ? value.map(node => this.fromSoapResult(node, childType)) + : this.fromSoapResult(value, childType); } return result; diff --git a/examples/generated-output/client/utils.ts b/examples/generated-output/client/utils.ts index 5f76e85..b5524cd 100644 --- a/examples/generated-output/client/utils.ts +++ b/examples/generated-output/client/utils.ts @@ -11,6 +11,8 @@ export interface WeatherDataTypes { Attributes: Record; /** Maps type names to their child element types for recursive processing */ ChildrenTypes: Record>>; + /** Maps type names to repeated child properties; optional for custom metadata compatibility. */ + RepeatedElements?: Record; } export const WEATHER_DATA_TYPES: WeatherDataTypes = { @@ -97,5 +99,25 @@ export const WEATHER_DATA_TYPES: WeatherDataTypes = { "GetCityWeatherByZIPResponse": { "GetCityWeatherByZIPResult": "WeatherReturn" } + }, + "RepeatedElements": { + "ArrayOfForecast": [ + "Forecast" + ], + "ArrayOfWeatherDescription": [ + "WeatherDescription" + ], + "Forecast": [], + "ForecastReturn": [], + "GetCityForecastByZIP": [], + "GetCityForecastByZIPResponse": [], + "GetCityWeatherByZIP": [], + "GetCityWeatherByZIPResponse": [], + "GetWeatherInformation": [], + "GetWeatherInformationResponse": [], + "POP": [], + "Temp": [], + "WeatherDescription": [], + "WeatherReturn": [] } } as const; diff --git a/package-lock.json b/package-lock.json index 8823c27..43e1390 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@techspokes/typescript-wsdl-client", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techspokes/typescript-wsdl-client", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^13.1.0", diff --git a/package.json b/package.json index 0fa09e2..bd5b99d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techspokes/typescript-wsdl-client", - "version": "1.1.1", + "version": "1.1.2", "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.", "keywords": [ "wsdl", diff --git a/scripts/smoke-npm-package.mjs b/scripts/smoke-npm-package.mjs index 81accf5..69fdc2a 100644 --- a/scripts/smoke-npm-package.mjs +++ b/scripts/smoke-npm-package.mjs @@ -104,6 +104,14 @@ try { ].join("\n")); json(".generated/occurrence/tsconfig.json", {compilerOptions: {strict: true, noEmit: true, target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", skipLibCheck: true}, include: ["*.ts"]}); run(["node_modules/typescript/bin/tsc", "-p", ".generated/occurrence/tsconfig.json"]); + fs.copyFileSync(path.join(root, "test/helpers/occurrenceTransport.mjs"), path.join(consumer, "occurrenceTransport.mjs")); + write("occurrence-transport.mjs", [ + 'import {OccurrenceService} from "./.generated/occurrence-js/client.js";', + 'import {verifyOccurrenceTransport} from "./occurrenceTransport.mjs";', + 'await verifyOccurrenceTransport({client: new OccurrenceService({source: "occurrence.wsdl", options: {timeout: 5000}})});', + ].join("\n")); + run(["node_modules/typescript/bin/tsc", "-p", ".generated/occurrence/tsconfig.json", "--noEmit", "false", "--outDir", ".generated/occurrence-js"]); + run(["occurrence-transport.mjs"]); run(["node_modules/typescript/bin/tsc", "--noEmit", "-p", ".generated/app/tsconfig.json"]); run([path.join(consumer, "node_modules/vitest/vitest.mjs"), "run", "--config", "vitest.config.ts"], path.join(consumer, ".generated/tests")); diff --git a/src/client/generateClient.ts b/src/client/generateClient.ts index 9972e67..8131d57 100644 --- a/src/client/generateClient.ts +++ b/src/client/generateClient.ts @@ -188,6 +188,7 @@ export type ${clientName}Response> = (typeName && this.dataTypes?.ChildrenTypes?.[typeName]) || {}; + const repeatedElements: readonly string[] = (typeName && this.dataTypes?.RepeatedElements?.[typeName]) || []; const result: any = {}; // Preserve text content for mixed XML elements @@ -433,9 +435,11 @@ ${methodsBody} } // Recursively convert child elements with their specific type info const childType: string | undefined = childrenTypes[k]; - result[k] = Array.isArray(v) - ? v.map(node => this.fromSoapResult(node, childType)) - : this.fromSoapResult(v, childType); + // node-soap can return a scalar when repetition is declared on a wrapping sequence (#141). + const value = v != null && !Array.isArray(v) && repeatedElements.includes(k) ? [v] : v; + result[k] = Array.isArray(value) + ? value.map(node => this.fromSoapResult(node, childType)) + : this.fromSoapResult(value, childType); } return result; diff --git a/src/client/generateUtils.ts b/src/client/generateUtils.ts index f8b4901..0915ac2 100644 --- a/src/client/generateUtils.ts +++ b/src/client/generateUtils.ts @@ -43,8 +43,17 @@ export function generateUtils(outFile: string, compiled: CompiledCatalog) { if (typeof attrSpec !== "object" || typeof childType !== "object") { throw new Error("Invalid metadata structure. Expected objects for Attributes and ChildrenTypes."); } + const repeatedElements = Object.fromEntries( + Object.entries(compiled.meta.propMeta ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([typeName, properties]) => [ + typeName, + Object.keys(properties).filter(name => { + const max = properties[name]?.max; + return max === "unbounded" || (typeof max === "number" && max > 1); + }).sort(), + ]), + ); const metas = JSON.stringify( - {Attributes: attrSpec, ChildrenTypes: childType}, + {Attributes: attrSpec, ChildrenTypes: childType, RepeatedElements: repeatedElements}, null, 2 ); @@ -57,6 +66,8 @@ export interface ${clientName}DataTypes { Attributes: Record; /** Maps type names to their child element types for recursive processing */ ChildrenTypes: Record>>; + /** Maps type names to repeated child properties; optional for custom metadata compatibility. */ + RepeatedElements?: Record; } export const ${clientConstant}_DATA_TYPES: ${clientName}DataTypes = ${metas} as const;\n`; diff --git a/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl b/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl index ab1921d..52f86d9 100644 --- a/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl +++ b/test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl @@ -11,6 +11,12 @@ + + + + + + @@ -22,6 +28,7 @@ + diff --git a/test/conformance/registry.ts b/test/conformance/registry.ts index 1515159..b9a7106 100644 --- a/test/conformance/registry.ts +++ b/test/conformance/registry.ts @@ -1,4 +1,8 @@ import type {CapabilityCase} from "./types.js"; +import {resolve, join} from "node:path"; +import {pathToFileURL} from "node:url"; +import Fastify from "fastify"; +import {verifyOccurrenceTransport} from "../helpers/occurrenceTransport.mjs"; function requireSchema(doc: any, name: string): any { const schema = doc.components?.schemas?.[name]; @@ -50,7 +54,7 @@ export const capabilities: CapabilityCase[] = [ docsAnchor: "fully-supported", publicContract: "Wrapping sequences with nonzero maxima propagate element bounds and optionality without crossing choice or all boundaries.", decision: "support", - decisionReason: "Regression evidence for #141 covers catalog bounds, client arrays, and flattened gateway responses.", + decisionReason: "Regression evidence for #141 covers catalog bounds, client arrays, and real singleton and repeated SOAP responses through the gateway.", authority: "XML Schema 1.0", provenance: "Synthetic fixture based on the public sequence wrapper example in issue #141; no private WSDL content.", license: "MIT", @@ -95,6 +99,21 @@ export const capabilities: CapabilityCase[] = [ }, gateway: { outcome: "success", + assert: async ({clientDir, gatewayDir}) => { + const {OccurrenceService} = await import(pathToFileURL(join(clientDir, "client.ts")).href); + const plugin = (await import(pathToFileURL(join(gatewayDir, "plugin.ts")).href)).default; + await verifyOccurrenceTransport({ + client: new OccurrenceService({ + source: resolve("test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl"), + options: {timeout: 5_000}, + }), + createGateway: async client => { + const app = Fastify(); + await app.register(plugin, {client}); + return {app, flatten: true}; + }, + }); + }, requests: [ {requestId: "omitted"}, {requestId: "repeated", bounded: ["a", "b"], unbounded: ["c", "d"]}, diff --git a/test/helpers/occurrenceTransport.d.mts b/test/helpers/occurrenceTransport.d.mts new file mode 100644 index 0000000..44fddcf --- /dev/null +++ b/test/helpers/occurrenceTransport.d.mts @@ -0,0 +1,11 @@ +import type {FastifyInstance} from "fastify"; + +export interface OccurrenceClient { + soapClient(): Promise<{setEndpoint(endpoint: string): void}>; + SubmitOccurrence(args: unknown): Promise<{response: unknown}>; +} + +export function verifyOccurrenceTransport(options: { + client: OccurrenceClient; + createGateway?: (client: OccurrenceClient) => Promise<{app: FastifyInstance; flatten: boolean}>; +}): Promise; diff --git a/test/helpers/occurrenceTransport.mjs b/test/helpers/occurrenceTransport.mjs new file mode 100644 index 0000000..ad782b9 --- /dev/null +++ b/test/helpers/occurrenceTransport.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import {createServer} from "node:http"; +import {once} from "node:events"; + +/** + * Verifies generated clients against real singleton and repeated SOAP responses. + * @param {{client: object, createGateway?: Function}} options Generated consumer artifacts only. + * @returns {Promise} Resolves after transport, DTO, and optional gateway assertions pass. + */ +export async function verifyOccurrenceTransport({client, createGateway}) { + let count = 1; + let requestXml = ""; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + requestXml = Buffer.concat(chunks).toString("utf8"); + const addresses = Array.from({length: count}, (_, index) => + `Street ${index}`).join(""); + const optional = count === 2 + ? "Optional" + : ""; + const labels = count === 2 ? "firstsecond" : "only"; + response.setHeader("Content-Type", "text/xml; charset=utf-8"); + response.end(`${addresses}${optional}${labels}`); + }); + server.requestTimeout = 5_000; + server.listen(0, "127.0.0.1"); + let gateway; + try { + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + (await client.soapClient()).setEndpoint(`http://127.0.0.1:${address.port}`); + gateway = await createGateway?.(client); + for (count of [1, 2]) { + const payload = count === 1 ? {requestId: "omitted"} + : {requestId: "repeated", bounded: ["a", "b"], unbounded: ["c", "d"]}; + const expected = { + addresses: {address: Array.from({length: count}, (_, index) => ({street: `Street ${index}`}))}, + ...(count === 2 ? {optionalAddresses: {address: [{street: "Optional"}]}} : {}), + label: count === 1 ? ["only"] : ["first", "second"], + }; + const result = await bounded(client.SubmitOccurrence(payload)); + assert.deepEqual(result.response, expected, `SOAP ${count}-item DTO must match its array contract`); + assertRequest(requestXml, count); + if (gateway) { + const response = await bounded(gateway.app.inject({method: "POST", url: "/submit-occurrence", payload})); + assert.equal(response.statusCode, 200, response.body); + const data = gateway.flatten ? { + ...expected, + addresses: expected.addresses.address, + ...(count === 2 ? {optionalAddresses: expected.optionalAddresses.address} : {}), + } : expected; + assert.deepEqual(response.json(), {status: "SUCCESS", message: null, data, error: null}); + assertRequest(requestXml, count); + } + } + } finally { + try { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } finally { + await gateway?.app.close(); + } + } +} + +async function bounded(operation) { + let timer; + try { + return await Promise.race([operation, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("SOAP transport assertion exceeded 10 seconds")), 10_000); + })]); + } finally { + clearTimeout(timer); + } +} + +function assertRequest(xml, count) { + for (const [name, values] of [["bounded", ["a", "b"]], ["unbounded", ["c", "d"]]]) { + const matches = [...xml.matchAll(new RegExp(`<(?:[\\w.-]+:)?${name}>([^<]*)`, "g"))]; + assert.deepEqual(matches.map(match => match[1]), count === 1 ? [] : values, `${name} SOAP request elements`); + } +} diff --git a/test/integration/sequence-occurrence-transport.test.ts b/test/integration/sequence-occurrence-transport.test.ts new file mode 100644 index 0000000..f628af9 --- /dev/null +++ b/test/integration/sequence-occurrence-transport.test.ts @@ -0,0 +1,56 @@ +import {expect, it} from "vitest"; +import {mkdirSync, mkdtempSync, rmSync} from "node:fs"; +import {join, resolve} from "node:path"; +import {pathToFileURL} from "node:url"; +import Fastify from "fastify"; +import {runGenerationPipeline} from "../../src/pipeline.js"; +import {verifyOccurrenceTransport} from "../helpers/occurrenceTransport.mjs"; + +const fixture = resolve("test/conformance/fixtures/xsd/sequences/sequence-occurrence-wrappers.wsdl"); + +it.each([true, false])("normalizes real SOAP responses with flattenArrayWrappers=%s", async flatten => { + mkdirSync("tmp/conformance", {recursive: true}); + const outDir = mkdtempSync(resolve("tmp/conformance/occurrence-transport-")); + try { + await runGenerationPipeline({ + wsdl: fixture, + catalogOut: join(outDir, "client/catalog.json"), + clientOutDir: join(outDir, "client"), + openapi: {outFile: join(outDir, "openapi.json"), format: "json", flattenArrayWrappers: flatten}, + gateway: {outDir: join(outDir, "gateway"), serviceSlug: "occurrence", versionSlug: "v1"}, + compiler: {imports: "js"}, + }); + const {OccurrenceService} = await import(pathToFileURL(join(outDir, "client/client.ts")).href); + const client = new OccurrenceService({source: fixture, options: {timeout: 5_000}}); + for (const type of ["Addresses", "OptionalAddresses", "ExtendedAddresses", "AddressCollection"]) { + expect(client.fromSoapResult({address: {street: "single"}}, type)).toEqual({address: [{street: "single"}]}); + } + expect(client.fromSoapResult({label: "", addresses: {address: {street: "nested"}}}, "SubmitOccurrenceResponse")) + .toEqual({label: [""], addresses: {address: [{street: "nested"}]}}); + for (const value of [false, 0, ""]) { + expect(client.fromSoapResult({label: value}, "SubmitOccurrenceResponse")).toEqual({label: [value]}); + } + expect(client.fromSoapResult({label: null}, "SubmitOccurrenceResponse")).toEqual({label: null}); + expect(client.fromSoapResult({label: undefined}, "SubmitOccurrenceResponse")).toEqual({label: undefined}); + expect(client.fromSoapResult({}, "SubmitOccurrenceResponse")).toEqual({}); + expect(client.fromSoapResult({street: "scalar", unknown: "value", attributes: {id: "1"}, $value: "text"}, "AddressType")) + .toEqual({street: "scalar", unknown: "value", id: "1", $value: "text"}); + const array = {label: ["first", "second"]}; + expect(client.fromSoapResult(array, "SubmitOccurrenceResponse")).toEqual(array); + const repeated = client.dataTypes.RepeatedElements; + client.dataTypes = {...client.dataTypes, RepeatedElements: undefined}; + expect(client.fromSoapResult({label: "legacy"}, "SubmitOccurrenceResponse")).toEqual({label: "legacy"}); + client.dataTypes.RepeatedElements = repeated; + const plugin = (await import(pathToFileURL(join(outDir, "gateway/plugin.ts")).href)).default; + await verifyOccurrenceTransport({ + client, + createGateway: async client => { + const app = Fastify(); + await app.register(plugin, {client}); + return {app, flatten}; + }, + }); + } finally { + rmSync(outDir, {recursive: true, force: true}); + } +}, 30_000); diff --git a/test/snapshot/__snapshots__/pipeline.test.ts.snap b/test/snapshot/__snapshots__/pipeline.test.ts.snap index d8858fa..e96cd9e 100644 --- a/test/snapshot/__snapshots__/pipeline.test.ts.snap +++ b/test/snapshot/__snapshots__/pipeline.test.ts.snap @@ -1149,6 +1149,7 @@ export type WeatherResponse> * @property dataTypes - Metadata for data types used in serialization/deserialization. * @property dataTypes.Attributes - Maps type names to lists of property names that should be treated as XML attributes. * @property dataTypes.ChildrenTypes - Maps type names to their child element types for recursive processing. + * @property dataTypes.RepeatedElements - Identifies child properties that must retain their array shape for singleton responses. * * * @note Have fun with the generated client! If TechSpokes made your day (or a week), @@ -1430,6 +1431,7 @@ export class Weather { // Get child type mapping for recursive processing with correct types const childrenTypes: Readonly> = (typeName && this.dataTypes?.ChildrenTypes?.[typeName]) || {}; + const repeatedElements: readonly string[] = (typeName && this.dataTypes?.RepeatedElements?.[typeName]) || []; const result: any = {}; // Preserve text content for mixed XML elements @@ -1453,9 +1455,11 @@ export class Weather { } // Recursively convert child elements with their specific type info const childType: string | undefined = childrenTypes[k]; - result[k] = Array.isArray(v) - ? v.map(node => this.fromSoapResult(node, childType)) - : this.fromSoapResult(v, childType); + // node-soap can return a scalar when repetition is declared on a wrapping sequence (#141). + const value = v != null && !Array.isArray(v) && repeatedElements.includes(k) ? [v] : v; + result[k] = Array.isArray(value) + ? value.map(node => this.fromSoapResult(node, childType)) + : this.fromSoapResult(value, childType); } return result; @@ -1696,6 +1700,8 @@ export interface WeatherDataTypes { Attributes: Record; /** Maps type names to their child element types for recursive processing */ ChildrenTypes: Record>>; + /** Maps type names to repeated child properties; optional for custom metadata compatibility. */ + RepeatedElements?: Record; } export const WEATHER_DATA_TYPES: WeatherDataTypes = { @@ -1782,6 +1788,26 @@ export const WEATHER_DATA_TYPES: WeatherDataTypes = { "GetCityWeatherByZIPResponse": { "GetCityWeatherByZIPResult": "WeatherReturn" } + }, + "RepeatedElements": { + "ArrayOfForecast": [ + "Forecast" + ], + "ArrayOfWeatherDescription": [ + "WeatherDescription" + ], + "Forecast": [], + "ForecastReturn": [], + "GetCityForecastByZIP": [], + "GetCityForecastByZIPResponse": [], + "GetCityWeatherByZIP": [], + "GetCityWeatherByZIPResponse": [], + "GetWeatherInformation": [], + "GetWeatherInformationResponse": [], + "POP": [], + "Temp": [], + "WeatherDescription": [], + "WeatherReturn": [] } } as const; " diff --git a/test/unit/compiler-compositor-occurs.test.ts b/test/unit/compiler-compositor-occurs.test.ts index 154ddfb..339db10 100644 --- a/test/unit/compiler-compositor-occurs.test.ts +++ b/test/unit/compiler-compositor-occurs.test.ts @@ -2,6 +2,8 @@ import {afterAll, describe, expect, it} from "vitest"; import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs"; import {join} from "node:path"; import {generateTypes} from "../../src/client/generateTypes.js"; +import {generateUtils} from "../../src/client/generateUtils.js"; +import {pathToFileURL} from "node:url"; import {generateSchemas} from "../../src/openapi/generateSchemas.js"; import {compileCatalog, type CompiledCatalog} from "../../src/compiler/schemaCompiler.js"; import {resolveCompilerOptions} from "../../src/config.js"; @@ -188,6 +190,11 @@ describe("sequence occurrence consumer contracts", () => { ] as const)("retains the intended boundary for %s", async (name, body, min, max) => { const compiled = await compileFromFixture(buildWsdl(`${body}`), name); expect(compiled.types.find(t => t.name === "PingResponse")?.elems.find(e => e.name === "item")).toMatchObject({min, max}); + const utilsFile = join(tmpRoot, `${name}-utils.ts`); + generateUtils(utilsFile, compiled); + const utils = await import(pathToFileURL(utilsFile).href); + const metadata = Object.values(utils)[0] as {RepeatedElements: Record}; + expect(metadata.RepeatedElements.PingResponse).toEqual(max > 1 ? ["item"] : []); }); it("keeps inline type scope separate and extension locals multiplied once", async () => {