Skip to content

Latest commit

 

History

History
404 lines (289 loc) · 14.2 KB

File metadata and controls

404 lines (289 loc) · 14.2 KB

Core Concepts and Advanced Topics

This document covers the design principles and modeling strategies used by TypeScript WSDL Client. For installation, quick-start, and CLI usage see the README.

Flattening and $value

Attributes and elements become peer properties with no nested wrapper noise.

Given a WSDL complex type with simple content:

<xs:complexType name="Price">
  <xs:simpleContent>
    <xs:extension base="xs:decimal">
      <xs:attribute name="currency" type="xs:string"/>
    </xs:extension>
  </xs:simpleContent>
</xs:complexType>

The generator produces a flat interface:

interface Price {
  currency?: string;  // attribute
  $value: string;     // text content (decimal mapped to string by default)
}

Primitive Mapping

All XSD numeric and date-time types map to string by default. This prevents precision loss at the cost of convenience.

XSD Type Default Override Options When to Override
xs:long string number, bigint Use number if values fit JS range
xs:integer string number Use string for arbitrary-size ints
xs:decimal string number Use string for precise decimals
xs:dateTime string Date Use Date if runtime parsing is okay

Override with CLI flags:

  • --client-int64-as
  • --client-decimal-as
  • --client-date-as

Simple Type Aliases

Named xs:simpleType declarations generate scalar TypeScript aliases. Enumerated restrictions generate string literal unions.

<xs:simpleType name="MyEnum">
  <xs:restriction base="xs:string">
    <xs:enumeration value="Red"/>
    <xs:enumeration value="Green"/>
  </xs:restriction>
</xs:simpleType>

The generated TypeScript type preserves the enum as a scalar alias:

export type MyEnum = "Red" | "Green";

The generated OpenAPI schema uses the same component name with a scalar enum schema:

{
  "MyEnum": {
    "type": "string",
    "enum": ["Red", "Green"]
  }
}

Same-Name Global Elements

Some WSDLs declare a named simple type and a global element with the same local name. When the element references that simple type, the generator treats the element as the scalar alias rather than creating a wrapper interface.

<xs:simpleType name="MyEnum">
  <xs:restriction base="xs:string">
    <xs:enumeration value="Red"/>
    <xs:enumeration value="Green"/>
  </xs:restriction>
</xs:simpleType>
<xs:element name="MyEnum" nillable="true" type="tns:MyEnum"/>

The generated TypeScript remains a single declaration:

export type MyEnum = "Red" | "Green";

Operation methods that use tns:MyEnum as their root element accept and return MyEnum directly:

interface EnumServiceOperations {
  Echo(args: MyEnum): Promise<{ response: MyEnum; headers: unknown }>;
}

This avoids invalid duplicate declarations such as type MyEnum plus interface MyEnum. It also keeps OpenAPI request and response schemas pointed at the scalar MyEnum component.

The same-name scalar element does not create an object wrapper only to carry element metadata. A root element marked nillable="true" still uses the scalar alias as the operation type.

Different-Name Simple Elements

When a global element has a different name from the named simple type it references, the element remains a wrapper surface type. The simple type alias is still generated separately.

<xs:simpleType name="MyEnum">
  <xs:restriction base="xs:string">
    <xs:enumeration value="Red"/>
    <xs:enumeration value="Green"/>
  </xs:restriction>
</xs:simpleType>
<xs:element name="FavoriteColor" type="tns:MyEnum"/>

The generated surface type keeps the element name and stores the scalar value in $value:

export type MyEnum = "Red" | "Green";

export interface FavoriteColor {
  $value?: MyEnum;
}

Deterministic Generation

All output is stable and diff-friendly for CI/CD pipelines.

  • Sorted type declarations
  • Sorted OpenAPI paths, schemas, and parameters
  • Sorted JSON schema keys
  • Stable alias resolution
  • Consistent import ordering

Regenerate safely without spurious diffs in version control.

Catalog as Intermediate Artifact

catalog.json is the compiled representation of your WSDL. It is debuggable, cacheable, and reused across client, OpenAPI, and gateway generation.

Inspect types, operations, and metadata as plain JSON. The catalog is automatically placed alongside generated output.

The catalog stores optional human-readable doc fields extracted from WSDL/XSD documentation nodes. These fields are additive metadata used by TypeScript, OpenAPI, gateway, and generated-test emitters and do not change runtime behavior.

The catalog may also store optional diagnostics.notes entries. These notes record non-error modeling decisions, such as reusing a same-name simple type alias instead of emitting a duplicate wrapper interface. CLI commands print these entries as Note: lines during compilation.

The catalog also stores optional wsdlDocs metadata for selected WSDL nodes:

  • bindings[]
  • messages[]
  • messages[].parts[]
  • services[]
  • services[].ports[]

OpenAPI uses operation docs for both description and default summary values. ops.json keeps precedence when summary or description is explicitly provided.

Catalog Locations by Command

Command Location
client {client-dir}/catalog.json
openapi {openapi-dir}/catalog.json
pipeline First available output directory

Response Envelope

All gateway responses follow a uniform envelope structure. This has been always-on since v0.7.1.

Success Response

{
  "status": "SUCCESS",
  "message": null,
  "data": { },
  "error": null
}

Error Response

{
  "status": "ERROR",
  "message": "Request validation failed",
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": { }
  }
}

Envelope Shape

The base envelope is generic over the payload type T:

{
  status: string;
  message: string | null;
  data: T | null;
  error: ErrorObject | null;
}

The error object carries a machine-readable code, a human message, and optional details:

{
  code: string;
  message: string;
  details: object | null;
}

Streaming Bypass

Operations opted into streaming with --stream-config bypass the success envelope on the 200 response path. The OpenAPI response content is declared as the configured stream media type, and the gateway writes raw NDJSON lines or one streamed JSON array straight to the response body. Error responses (400, 502, and the rest) still use the normal envelope so clients always see structured failures before the first record. See ADR-002 for the full rationale.

Envelope Naming

The base envelope is named ${serviceName}ResponseEnvelope. Override with --openapi-envelope-namespace.

The error type is named ${serviceName}ErrorObject. Override with --openapi-error-namespace.

Per-operation envelopes use the pattern <PayloadType|OperationName><EnvelopeNamespace>.

Collision Avoidance

When the payload type already ends with the namespace prefix, an underscore is inserted. For example, WeatherResponse combined with ResponseEnvelope produces WeatherResponse_ResponseEnvelope.

Choice Element Handling

The default strategy is all-optional. Choice branches are emitted on a single interface using the occurrence metadata compiled from the WSDL.

// WSDL: <xs:choice>
interface MyType {
  optionA?: string;
  optionB?: number;
}

--client-choice-mode union is opt-in. It emits a base object for non-choice fields and an exclusive generated branch union for choice fields. Peer branch properties use ?: never, so TypeScript rejects payloads that include more than one branch.

type MyType = MyTypeChoiceBase & MyTypeChoice1;

interface MyTypeChoiceBase {
  id: string;
}

type MyTypeChoice1 =
  | { optionA: string; optionB?: never }
  | { optionB: number; optionA?: never };

OpenAPI generation also consumes the same choice metadata in union mode. It keeps choice member properties on the containing object and adds oneOf branch constraints with peer not checks so generated request schemas reject payloads that send multiple branches.

Array Wrapper Flattening

A complex type whose only child is a single repeated element with no attributes collapses to an array schema in OpenAPI. Controlled by --openapi-flatten-array-wrappers (default true).

<xs:complexType name="ArrayOfForecast">
  <xs:sequence>
    <xs:element name="Forecast" type="tns:Forecast" maxOccurs="unbounded"/>
  </xs:sequence>
</xs:complexType>

With flattening enabled (default), the OpenAPI schema becomes a plain array:

{
  "ArrayOfForecast": {
    "type": "array",
    "items": { "$ref": "#/components/schemas/Forecast" }
  }
}

The TypeScript types preserve the wrapper structure:

export interface ArrayOfForecast {
  Forecast?: Forecast[];
}

Runtime Unwrap

Because the SOAP client returns wrapper-shaped objects ({ Forecast: [...] }) while the OpenAPI schema expects flat arrays, the generated gateway includes an unwrapArrayWrappers() function in runtime.ts. Route handlers call it automatically before serialization. This bridges the TS-type/schema gap without requiring consumers to transform responses manually.

Disabling Flattening

Pass --openapi-flatten-array-wrappers false to preserve the wrapper object structure in OpenAPI schemas. When disabled:

  • ArrayOf* types emit as type: "object" with their inner element as a property
  • No unwrapArrayWrappers() function is generated in runtime.ts
  • Route handlers pass SOAP responses through unmodified
  • The OpenAPI schema matches the TypeScript types exactly

Inheritance Flattening

Three XSD inheritance patterns are supported.

Extension

Base properties are merged into the derived type. TypeScript uses extends when possible.

Restriction

Treated as the base type with additional constraints applied.

SimpleContent

The base value collapses into a $value property. Attributes remain as peer properties on the same interface.

Validation

OpenAPI output is validated with @apidevtools/swagger-parser. The validator checks schema structure, resolves all $ref references, catches missing schemas, and detects circular dependencies.

CLI OpenAPI generation always validates the generated spec. Programmatic callers can set skipValidate: true when they need to inspect an intermediate state.

Streaming vs Buffered Responses

The generator produces two response execution models. Buffered is the default and only path for operations not listed in a stream config. Streaming is opt-in and operation-scoped; it changes the emitted client method signature, the OpenAPI response description, and the Fastify route shape for that operation only.

Execution Model Contrast

Buffered operations call node-soap, wait for the full response to materialize, and return { response, headers, responseRaw, requestRaw }. Streaming operations bypass node-soap, POST a hand-built SOAP envelope via fetch, and return StreamOperationResponse<RecordType> with records: AsyncIterable<RecordType>. The SAX parser in runtime/streamXml.ts walks the configured recordPath and yields each record as its closing tag arrives.

The catalog is the source of truth for stream metadata. Each opted-in operation carries an OperationStreamMetadata entry, and downstream emitters (client, OpenAPI, gateway, tests) all read from the catalog. OpenAPI carries a derived view via the x-wsdl-tsc-stream extension.

Terminal-Error Policy

Errors before the first record use the normal gateway error envelope because the response headers and status have not been committed yet. Errors mid-stream truncate the response; NDJSON consumers detect this as an incomplete HTTP response, and JSON array consumers see an incomplete or invalid JSON document. Both cases must be treated as failed streams. This behavior is documented for operators in the Production Guide.

Companion Catalogs and Shape Resolution

Some vendor WSDLs split their types across multiple services. The stream wrapper operation lives in one WSDL while the concrete record type lives in a companion WSDL. The stream config's shapeCatalogs section names additional WSDL or catalog inputs used only to resolve record shapes.

How Resolution Works

When an operation names a shapeCatalog, the compiler loads the companion catalog once, copies the reachable record-type graph into the current compilation, and fails loudly on structural name collisions. Structurally identical types dedupe silently, so two catalogs that share a common base type do not conflict.

{
  "shapeCatalogs": {
    "main": { "wsdlSource": "https://api.example.com/Main.svc?singleWsdl" }
  },
  "operations": {
    "StreamOp": {
      "recordType": "ConcreteRecordType",
      "recordPath": ["StreamOpResponse", "Records", "Record"],
      "shapeCatalog": "main"
    }
  }
}

Collision Handling

A structural collision means two types share a name but differ in fields. The build fails with a diagnostic naming both source catalogs. Rename in the companion source or point recordType at a distinct subtree. Silent renames are intentionally disallowed because they would produce ambiguous public APIs.

xs:any Wildcard Retention

XSD wildcards (<xs:any>) were silently dropped by earlier compiler versions. Since 0.17.0 the compiler retains them on the compiled type alongside any concrete children. This enables two downstream behaviors: honest stream-candidate detection (a wrapper that contains a wildcard is a likely streaming target) and accurate companion-catalog resolution (the compiler knows which elements are open for record types to slot into).

The retained wildcard is metadata only. It does not emit TypeScript any or loosen the generated types; concrete fields remain strictly typed.