/** * Tina4 WSDL/SOAP — SOAP 1.1 / WSDL 1.1 service base class. * * Auto-generates WSDL definitions and handles SOAP XML requests. * Zero external dependencies — uses simple string parsing for XML. * * Matches the PHP reference implementation (Tina4\WSDL). * * import { WSDLService, WSDLOperation } from "@tina4/core"; * * class Calculator extends WSDLService { * serviceName = "Calculator"; * serviceUrl = "/api/calculator"; * * @WSDLOperation({ output: { Result: "int" } }) * async Add(a: number, b: number): Promise> { * return { Result: a + b }; * } * } */ import { Log } from "./logger.js"; import { isDebugMode } from "./errorOverlay.js"; // ── Types ──────────────────────────────────────────────────── export interface WSDLOperationMeta { name: string; description?: string; input?: Record; // param name -> type output?: Record; // return name -> type } interface WSDLOperationConfig { description?: string; input?: Record; output?: Record; } // ── Namespace constants ────────────────────────────────────── const NS_SOAP = "http://schemas.xmlsoap.org/wsdl/soap/"; const NS_WSDL = "http://schemas.xmlsoap.org/wsdl/"; const NS_XSD = "http://www.w3.org/2001/XMLSchema"; const NS_SOAP_ENV = "http://schemas.xmlsoap.org/soap/envelope/"; /** TypeScript/JavaScript type name to XSD type mapping. */ const TYPE_MAP: Record = { int: "xsd:int", integer: "xsd:int", float: "xsd:float", double: "xsd:double", number: "xsd:double", numeric: "xsd:double", string: "xsd:string", bool: "xsd:boolean", boolean: "xsd:boolean", }; // ── XML helpers ────────────────────────────────────────────── /** * Escape special XML characters. */ function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // ── Minimal zero-dep XML DOM parser ──────────────────────────── // Stack-based parser that builds a tree. Handles namespaces by // stripping prefixes. No external dependencies. interface XmlNode { tag: string; children: XmlNode[]; text: string; } function parseXml(xml: string): XmlNode { const root: XmlNode = { tag: "", children: [], text: "" }; const stack: XmlNode[] = [root]; let i = 0; while (i < xml.length) { if (xml[i] === "<") { const closeIdx = xml.indexOf(">", i); if (closeIdx === -1) break; const tagContent = xml.substring(i + 1, closeIdx).trim(); if (tagContent.startsWith("/")) { stack.pop(); } else if (tagContent.startsWith("?") || tagContent.startsWith("!")) { // PI or comment — skip } else { const selfClosing = tagContent.endsWith("/"); const raw = selfClosing ? tagContent.slice(0, -1).trim() : tagContent; const spaceIdx = raw.indexOf(" "); const fullTag = spaceIdx === -1 ? raw : raw.substring(0, spaceIdx); const colonIdx = fullTag.indexOf(":"); const localTag = colonIdx === -1 ? fullTag : fullTag.substring(colonIdx + 1); const node: XmlNode = { tag: localTag, children: [], text: "" }; stack[stack.length - 1].children.push(node); if (!selfClosing) stack.push(node); } i = closeIdx + 1; } else { const nextTag = xml.indexOf("<", i); const text = (nextTag === -1 ? xml.substring(i) : xml.substring(i, nextTag)).trim(); if (text && stack.length > 1) { stack[stack.length - 1].text += text; } i = nextTag === -1 ? xml.length : nextTag; } } return root; } function findNode(node: XmlNode, tagName: string): XmlNode | null { if (node.tag === tagName) return node; for (const child of node.children) { const found = findNode(child, tagName); if (found) return found; } return null; } function extractElement(xml: string, tagName: string): string | null { const tree = parseXml(xml); const node = findNode(tree, tagName); if (!node) return null; // Rebuild inner content from children if (node.children.length === 0) return node.text || null; // For complex content, fall back to regex on the original XML const pattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tagName}[^>]*>([\\s\\S]*?)`, "i"); const match = xml.match(pattern); return match ? match[1] : node.text || null; } function extractChildren(xml: string): Array<{ name: string; value: string }> { const tree = parseXml(xml); const target = tree.children.length === 1 ? tree.children[0] : tree; return target.children.map(c => ({ name: c.tag, value: c.text })); } function extractSoapBody(xml: string): string | null { return extractElement(xml, "Body"); } function extractOperation(bodyXml: string): { name: string; content: string } | null { const tree = parseXml(bodyXml); const firstChild = tree.children[0]; if (!firstChild) return null; const pattern = new RegExp(`<(?:[a-zA-Z0-9]+:)?${firstChild.tag}[^>]*>([\\s\\S]*?)`, "i"); const match = bodyXml.match(pattern); return { name: firstChild.tag, content: match ? match[1] : "" }; } // ── Metadata storage ───────────────────────────────────────── /** Symbol key for storing operation metadata on class prototypes. */ const WSDL_OPS_KEY = Symbol("wsdl_operations"); /** * Decorator function for marking methods as WSDL operations. * * @WSDLOperation({ description: "Add two numbers", input: { a: "int", b: "int" }, output: { Result: "int" } }) * async Add(a: number, b: number): Promise> { ... } */ export function WSDLOperation(config?: WSDLOperationConfig) { return function (_target: unknown, propertyKey: string, descriptor: PropertyDescriptor) { // Store metadata on the method itself const op: WSDLOperationMeta = { name: propertyKey, description: config?.description, input: config?.input, output: config?.output, }; if (!descriptor.value._wsdlOp) { descriptor.value._wsdlOp = op; } return descriptor; }; } // ── WSDLService ────────────────────────────────────────────── export abstract class WSDLService { abstract serviceName: string; abstract serviceUrl: string; protected namespace: string = "http://tina4.com/wsdl"; /** * Lifecycle hook: called before operation invocation. * Override to validate, log, or modify the incoming request. */ protected onRequest(_request: unknown): void { // no-op — override in subclass } /** * Lifecycle hook: called after operation returns. * Override to transform, audit, or enrich the result. * Must return the (possibly modified) result. */ protected onResult(result: Record): Record { return result; } /** Discovered operations (populated on first use). */ private _operations: Map | null = null; /** * Discover operations by scanning for methods with _wsdlOp metadata. */ private discoverOperations(): Map { if (this._operations) return this._operations; this._operations = new Map(); // Walk the prototype chain to find decorated methods let proto = Object.getPrototypeOf(this); while (proto && proto !== WSDLService.prototype && proto !== Object.prototype) { const names = Object.getOwnPropertyNames(proto); for (const name of names) { if (name === "constructor") continue; try { const method = (this as Record)[name]; if (typeof method === "function" && (method as unknown as Record)._wsdlOp) { const op = (method as unknown as Record)._wsdlOp as WSDLOperationMeta; if (!this._operations.has(name)) { this._operations.set(name, op); } } } catch { // skip non-accessible properties } } proto = Object.getPrototypeOf(proto); } return this._operations; } /** * Map a type name to an XSD type string. */ private typeToXsd(typeName: string): string { return TYPE_MAP[typeName] ?? "xsd:string"; } /** * Convert a string value from XML to the target type. */ private convertValue(value: string, typeName: string): unknown { switch (typeName) { case "int": case "integer": { // Match Python's int(value) — a non-numeric value RAISES rather than // silently yielding NaN. The thrown Error is caught in handle() and // becomes a Server fault. const n = parseInt(value, 10); if (Number.isNaN(n)) { throw new Error(`invalid integer value: ${JSON.stringify(value)}`); } return n; } case "float": case "double": case "number": case "numeric": { // Match Python's float(value) — non-numeric raises (→ Server fault). const f = parseFloat(value); if (Number.isNaN(f)) { throw new Error(`invalid numeric value: ${JSON.stringify(value)}`); } return f; } case "bool": case "boolean": return ["true", "1", "yes"].includes(value.toLowerCase()); default: return value; } } /** * Generate WSDL 1.1 XML document. */ generateWSDL(endpointUrl?: string): string { const ops = this.discoverOperations(); const tns = `urn:${this.serviceName}`; const url = endpointUrl ?? this.serviceUrl; const parts: string[] = []; parts.push(''); parts.push(``); parts.push(""); // Types parts.push(" "); parts.push(` `); for (const [opName, op] of ops) { // Request element parts.push(` `); parts.push(" "); parts.push(" "); if (op.input) { for (const [paramName, paramType] of Object.entries(op.input)) { const xsdType = this.typeToXsd(paramType); parts.push(` `); } } parts.push(" "); parts.push(" "); parts.push(` `); // Response element parts.push(` `); parts.push(" "); parts.push(" "); if (op.output) { for (const [retName, retType] of Object.entries(op.output)) { const xsdType = this.typeToXsd(retType); parts.push(` `); } } parts.push(" "); parts.push(" "); parts.push(` `); } parts.push(" "); parts.push(" "); parts.push(""); // Messages for (const [opName] of ops) { parts.push(` `); parts.push(` `); parts.push(" "); parts.push(` `); parts.push(` `); parts.push(" "); } parts.push(""); // PortType parts.push(` `); for (const [opName] of ops) { parts.push(` `); parts.push(` `); parts.push(` `); parts.push(" "); } parts.push(" "); parts.push(""); // Binding parts.push(` `); parts.push(' '); for (const [opName] of ops) { parts.push(` `); parts.push(` `); parts.push(' '); parts.push(' '); parts.push(" "); } parts.push(" "); parts.push(""); // Service parts.push(` `); parts.push(` `); parts.push(` `); parts.push(" "); parts.push(" "); parts.push(""); return parts.join("\n"); } /** * Handle incoming SOAP request (parse XML, dispatch to method, return SOAP response). */ async handle(soapXml: string = ""): Promise { const ops = this.discoverOperations(); // SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message. // Rejecting any DOCTYPE/DTD up front — BEFORE the body is parsed — also // closes the XML entity-expansion (billion-laughs) and external-entity // (XXE) attack surface regardless of parser internals. The operation // never runs. (Node's parser is hand-rolled and already immune; this is // defence in depth + consistent fault behaviour across all 4 frameworks.) if (/)[opName]; if (typeof method !== "function") { return this.soapFault("Client", `Operation not implemented: ${opName}`); } // Lifecycle hook: before invocation this.onRequest(soapXml); // Invoke the method. Parameter conversion runs INSIDE the try so a // non-numeric value for an int/float param (convertValue throws, matching // Python's int()/float() raise) becomes a Server fault — not a silent NaN. try { // Extract parameters from the operation element const children = extractChildren(operation.content); const params: unknown[] = []; if (opMeta.input) { for (const [paramName, paramType] of Object.entries(opMeta.input)) { const child = children.find((c) => c.name === paramName); if (child) { params.push(this.convertValue(child.value, paramType)); } else { params.push(null); } } } const rawResult = await (method as (...args: unknown[]) => Promise).call(this, ...params); // Lifecycle hook: after invocation — allow result transformation const result = this.onResult(rawResult as Record); return this.soapResponse(opName, result); } catch (err) { // Log the real cause, but only leak the detail to the client in debug // mode — a resolver exception can carry internal state (DB credentials, // file paths) that must not reach a SOAP client. const errMsg = err instanceof Error ? err.message : String(err); Log.error(`WSDL operation '${opName}' failed: ${errMsg}`); const detail = isDebugMode() ? errMsg : "Internal server error"; return this.soapFault("Server", detail); } } /** * Register this service's routes on a router. * GET /service-url?wsdl -> WSDL XML * POST /service-url -> Handle SOAP request */ register(router: { addRoute?: (method: string, path: string, handler: (req: unknown, res: unknown) => void) => void; }): void { if (!router.addRoute) { // Try to use the router as an object with get/post methods const r = router as Record; // Register GET for WSDL if (typeof r.get === "function") { (r.get as Function)(this.serviceUrl, (req: Record, res: Record) => { this.handleGetRequest(req, res); }); } // Register POST for SOAP if (typeof r.post === "function") { (r.post as Function)(this.serviceUrl, async (req: Record, res: Record) => { await this.handlePostRequest(req, res); }); } return; } // Use addRoute if available router.addRoute("GET", this.serviceUrl, (req, res) => { this.handleGetRequest(req as Record, res as Record); }); router.addRoute("POST", this.serviceUrl, async (req, res) => { await this.handlePostRequest(req as Record, res as Record); }); } /** * Handle GET request — return WSDL XML. */ private handleGetRequest(req: Record, res: Record): void { // Infer endpoint URL from request if possible let endpointUrl = this.serviceUrl; if (req.headers && typeof req.headers === "object") { const headers = req.headers as Record; const host = headers.host ?? "localhost"; const protocol = headers["x-forwarded-proto"] ?? "http"; endpointUrl = `${protocol}://${host}${this.serviceUrl}`; } const wsdl = this.generateWSDL(endpointUrl); if (typeof res.send === "function") { // Set content type if possible if (typeof res.setHeader === "function") { (res.setHeader as Function)("Content-Type", "text/xml; charset=UTF-8"); } (res.send as Function)(wsdl); } else if (typeof res.end === "function") { if (typeof res.writeHead === "function") { (res.writeHead as Function)(200, { "Content-Type": "text/xml; charset=UTF-8" }); } (res.end as Function)(wsdl); } } /** * Handle POST request — process SOAP XML. */ private async handlePostRequest(req: Record, res: Record): Promise { let xmlBody = ""; // Try to get body from request object if (typeof req.rawBody === "string") { xmlBody = req.rawBody; } else if (typeof req.body === "string") { xmlBody = req.body; } else if (typeof req.body === "object" && req.body !== null) { xmlBody = JSON.stringify(req.body); } if (!xmlBody) { const fault = this.soapFault("Client", "Empty request body"); if (typeof res.send === "function") { if (typeof res.status === "function") (res.status as Function)(400); if (typeof res.setHeader === "function") { (res.setHeader as Function)("Content-Type", "text/xml; charset=UTF-8"); } (res.send as Function)(fault); } return; } const soapResponse = await this.handle(xmlBody); if (typeof res.send === "function") { if (typeof res.setHeader === "function") { (res.setHeader as Function)("Content-Type", "text/xml; charset=UTF-8"); } (res.send as Function)(soapResponse); } else if (typeof res.end === "function") { if (typeof res.writeHead === "function") { (res.writeHead as Function)(200, { "Content-Type": "text/xml; charset=UTF-8" }); } (res.end as Function)(soapResponse); } } /** * Build a SOAP response XML envelope. */ private soapResponse(opName: string, result: Record): string { const parts: string[] = []; parts.push(''); parts.push(``); parts.push(""); parts.push(`<${opName}Response>`); if (result && typeof result === "object") { for (const [key, value] of Object.entries(result)) { if (value === null || value === undefined) { parts.push(`<${key} xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>`); } else if (Array.isArray(value)) { for (const item of value) { parts.push(`<${key}>${escapeXml(String(item))}`); } } else if (typeof value === "boolean") { parts.push(`<${key}>${value ? "true" : "false"}`); } else { parts.push(`<${key}>${escapeXml(String(value))}`); } } } parts.push(``); parts.push(""); parts.push(""); return parts.join("\n"); } /** * Build a SOAP fault response XML. */ private soapFault(code: string, message: string): string { return '' + `` + "" + "" + `${code}` + `${escapeXml(message)}` + "" + "" + ""; } }