From 95b1beffcfc1ea943c271cd045cdabdba2fa4e71 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 19 Aug 2026 22:30:50 -0700 Subject: [PATCH] Qualify namespaced OpenAPI XML attributes (#598) --- plugins/importer-openapi/src/index.ts | 54 +++++++++++++++++--- plugins/importer-openapi/tests/index.test.ts | 30 ++++++++++- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 4bc4c824..f6d2cb18 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -1164,17 +1164,31 @@ function valueToXml( } if (isRecord(value)) { const properties = toRecord(resolvedSchema.properties); + const entries = Object.entries(value).map(([name, propertyValue]) => { + const propertySchema = toRecord(importState.resolve(properties[name])); + return { name, propertyValue, propertySchema, xml: toRecord(propertySchema.xml) }; + }); + const usedPrefixes = new Set(["xml", "xmlns"]); + const prefixesByNamespace = new Map(); + for (const xml of [schemaXml, ...entries.map(({ xml }) => xml)]) { + const namespace = stringAt(xml, "namespace"); + const prefix = stringAt(xml, "prefix"); + if (prefix == null || prefix.length === 0) continue; + usedPrefixes.add(prefix); + if (namespace != null && namespace.length > 0 && !prefixesByNamespace.has(namespace)) { + prefixesByNamespace.set(namespace, prefix); + } + } const attributes: string[] = []; const attributeNamespaces: UnknownRecord[] = []; const children: string[] = []; - for (const [name, propertyValue] of Object.entries(value)) { - const propertySchema = toRecord(importState.resolve(properties[name])); - const xml = toRecord(propertySchema.xml); + for (const { name, propertyValue, propertySchema, xml } of entries) { if (xml.attribute === true) { + const attributeXml = qualifyXmlAttribute(xml, usedPrefixes, prefixesByNamespace); attributes.push( - `${qualifiedXmlName(name, xml)}="${escapeXml(stringifyExampleValue(propertyValue))}"`, + `${qualifiedXmlName(name, attributeXml)}="${escapeXml(stringifyExampleValue(propertyValue))}"`, ); - attributeNamespaces.push(xml); + attributeNamespaces.push(attributeXml); } else { children.push(valueToXml(propertyValue, propertySchema, importState, name)); } @@ -1195,9 +1209,9 @@ function xmlElement( const namespaces = new Map(); for (const metadata of [xml, ...additionalNamespaces]) { const namespace = stringAt(metadata, "namespace"); - if (namespace == null) continue; + if (namespace == null || namespace.length === 0) continue; const prefix = stringAt(metadata, "prefix"); - namespaces.set(prefix == null ? "xmlns" : `xmlns:${prefix}`, namespace); + namespaces.set(prefix == null || prefix.length === 0 ? "xmlns" : `xmlns:${prefix}`, namespace); } const namespaceAttributes = [...namespaces].map( ([attribute, namespace]) => `${attribute}="${escapeXml(namespace)}"`, @@ -1207,10 +1221,34 @@ function xmlElement( return `${openingTag}${content}`; } +function qualifyXmlAttribute( + xml: UnknownRecord, + usedPrefixes: Set, + prefixesByNamespace: Map, +): UnknownRecord { + const namespace = stringAt(xml, "namespace"); + const declaredPrefix = stringAt(xml, "prefix"); + if (namespace != null && namespace.length === 0) { + const { prefix: _prefix, ...unqualifiedXml } = xml; + return unqualifiedXml; + } + if (namespace == null || (declaredPrefix != null && declaredPrefix.length > 0)) return xml; + + const existingPrefix = prefixesByNamespace.get(namespace); + if (existingPrefix != null) return { ...xml, prefix: existingPrefix }; + + let suffix = 1; + while (usedPrefixes.has(`ns${suffix}`)) suffix++; + const generatedPrefix = `ns${suffix}`; + usedPrefixes.add(generatedPrefix); + prefixesByNamespace.set(namespace, generatedPrefix); + return { ...xml, prefix: generatedPrefix }; +} + function qualifiedXmlName(fallbackName: string, xml: UnknownRecord): string { const name = stringAt(xml, "name") ?? fallbackName; const prefix = stringAt(xml, "prefix"); - return prefix == null ? name : `${prefix}:${name}`; + return prefix == null || prefix.length === 0 ? name : `${prefix}:${name}`; } function escapeXml(value: string): string { diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index bae84448..6caa55cf 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -1043,6 +1043,31 @@ describe("importer-openapi", () => { example: "42", xml: { attribute: true, namespace: "urn:metadata", prefix: "m" }, }, + externalId: { + type: "string", + example: "external", + xml: { + attribute: true, + name: "external-id", + namespace: "urn:external", + prefix: "ns1", + }, + }, + tenant: { + type: "string", + example: "acme", + xml: { attribute: true, namespace: "urn:tenant" }, + }, + region: { + type: "string", + example: "west", + xml: { attribute: true, namespace: "urn:tenant" }, + }, + legacy: { + type: "string", + example: "plain", + xml: { attribute: true, namespace: "", prefix: "unbound" }, + }, tags: { type: "array", example: ["one", "two"], @@ -1094,7 +1119,10 @@ describe("importer-openapi", () => { expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: - '' + + '' + 'onetwo' + 'Ada' + 'A' +