Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 43 additions & 22 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export class ApimClient implements IApimClient {
private static readonly ASYNC_POLL_TIMEOUT_MS = 7.5 * 60 * 1000;
/** Default interval between async operation polls when no Retry-After header. */
private static readonly ASYNC_POLL_INTERVAL_MS = 5000;
/** Max DELETE attempts when APIM reports an optimistic-concurrency conflict. */
private static readonly DELETE_CONFLICT_RETRIES = 3;
/** Base delay between DELETE conflict retries (multiplied by attempt number). */
private static readonly DELETE_CONFLICT_RETRY_DELAY_MS = 2000;
/** Known ARM management plane host suffixes for URL validation. */
private static readonly ARM_HOSTS = [
'management.azure.com',
Expand Down Expand Up @@ -451,32 +455,49 @@ export class ApimClient implements IApimClient {
descriptor: ResourceDescriptor
): Promise<boolean> {
const url = buildArmUri(context, descriptor);

try {
const response = await this.request(url, { method: 'DELETE' });

if (response.status === 404) {
return false; // Already deleted
}

// Poll for long-running operations
if (response.status === 202) {
const asyncUrl = this.extractAsyncOperationUrl(response);
if (asyncUrl) {
await this.pollAsyncOperation(asyncUrl, context, descriptor, { treatMissingAsSuccess: true });
} else {
await this.pollProvisioningState(context, descriptor, {
treatMissingAsSuccess: true,
});
for (let attempt = 1; ; attempt++) {
try {
const response = await this.request(url, { method: 'DELETE' });

if (response.status === 404) {
return false; // Already deleted
}
}

return true;
} catch (error) {
if ((error as Error).message.includes('404')) {
return false;
// Poll for long-running operations
if (response.status === 202) {
const asyncUrl = this.extractAsyncOperationUrl(response);
if (asyncUrl) {
await this.pollAsyncOperation(asyncUrl, context, descriptor, { treatMissingAsSuccess: true });
} else {
await this.pollProvisioningState(context, descriptor, {
treatMissingAsSuccess: true,
});
}
}

return true;
} catch (error) {
const message = (error as Error).message;
if (message.includes('404')) {
return false;
}
// Transient optimistic-concurrency conflict: cascade deletes of related
// resources (subscriptions, product/gateway associations) can modify
// the resource while its async DELETE is in flight. Retry the DELETE.
const isConflict =
message.includes('[PreconditionFailed]') ||
(error instanceof HttpError && error.status === 412);
if (isConflict && attempt < ApimClient.DELETE_CONFLICT_RETRIES) {
logger.warn(
`Delete conflict for ${buildResourceLabel(descriptor)} ` +
`(attempt ${attempt}/${ApimClient.DELETE_CONFLICT_RETRIES}), retrying...`
);
await this.delay(ApimClient.DELETE_CONFLICT_RETRY_DELAY_MS * attempt);
continue;
}
throw error;
}
throw error;
}
}

Expand Down
219 changes: 219 additions & 0 deletions src/lib/wsdl-normalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
/**
* WSDL normalizer
*
* APIM's WSDL export regenerates the document from its internal API model and
* has a known defect: `wsdl:part element="..."` references are qualified with
* the WSDL targetNamespace prefix (`tns`) even when the element is declared in
* a different inline schema namespace. APIM's own importer then rejects the
* document with "Could not resolve type '{ns}Element'", breaking the
* extract → publish round trip for multi-namespace WSDLs.
*
* This module rewrites such unresolvable part references to the prefix of the
* inline schema that actually declares the element. Only references that are
* (a) unresolvable as-is and (b) declared in exactly one inline schema are
* rewritten; everything else is left untouched.
*/

import { logger } from './logger.js';

const NAME = String.raw`[\w.-]+`;

/**
* Apply all WSDL export-defect normalizations needed for the extract →
* publish round trip.
*/
export function normalizeWsdl(wsdl: string): string {
return normalizeWsdlServicePorts(normalizeWsdlPartReferences(wsdl));
}

/**
* Normalize `wsdl:part` element references so they resolve against the inline
* schemas of the document. Returns the input unchanged when no fix is needed.
*/
export function normalizeWsdlPartReferences(wsdl: string): string {
const rootMatch = new RegExp(`<(?:${NAME}:)?definitions\\b[^>]*>`).exec(wsdl);
if (!rootMatch) {
return wsdl;
}

const prefixToNs = parseXmlnsDeclarations(rootMatch[0]);
const globalElements = collectGlobalSchemaElements(wsdl);
if (globalElements.size === 0) {
return wsdl;
}

// First declared prefix wins for each namespace
const nsToPrefix = new Map<string, string>();
for (const [prefix, ns] of prefixToNs) {
if (!nsToPrefix.has(ns)) {
nsToPrefix.set(ns, prefix);
}
}

const newDeclarations: string[] = [];
let generatedPrefixCounter = 0;

const partRef = new RegExp(
`(<(?:${NAME}:)?part\\b[^>]*?\\belement=")(?:(${NAME}):)?(${NAME})(")`,
'g'
);

const rewritten = wsdl.replace(
partRef,
(full, before: string, prefix: string | undefined, localName: string, after: string) => {
// Only fix prefixed references resolvable at the root — anything else
// (local xmlns declarations, unprefixed refs) is left untouched.
if (!prefix) {
return full;
}
const referencedNs = prefixToNs.get(prefix);
if (!referencedNs) {
return full;
}

const declaredIn = globalElements.get(localName);
// Skip unknown or ambiguous elements (declared in several schemas)
if (!declaredIn || declaredIn.size !== 1) {
return full;
}
const actualNs = [...declaredIn][0];
if (actualNs === undefined || referencedNs === actualNs) {
return full; // already resolvable
}

let fixedPrefix = nsToPrefix.get(actualNs);
if (!fixedPrefix) {
fixedPrefix = `apiopsns${generatedPrefixCounter++}`;
nsToPrefix.set(actualNs, fixedPrefix);
newDeclarations.push(`xmlns:${fixedPrefix}="${actualNs}"`);
}

logger.debug(
`WSDL normalizer: rewriting wsdl:part reference ${prefix}:${localName} → ` +
`${fixedPrefix}:${localName} (element is declared in "${actualNs}")`
);
return `${before}${fixedPrefix}:${localName}${after}`;
}
);

if (rewritten === wsdl) {
return wsdl;
}

if (newDeclarations.length > 0) {
const rootTag = rootMatch[0];
const patchedRoot = `${rootTag.slice(0, -1)} ${newDeclarations.join(' ')}>`;
return rewritten.replace(rootTag, patchedRoot);
}

return rewritten;
}

/**
* APIM's WSDL export emits one `wsdl:port` per configured proxy hostname, but
* its importer only accepts a single service endpoint ("Multiple service
* endpoints available, only one can be imported at a time"). Keep the first
* port of each `wsdl:service` and drop the rest.
*/
export function normalizeWsdlServicePorts(wsdl: string): string {
const serviceRe = new RegExp(
`<(${NAME}:)?service\\b[^>]*>[\\s\\S]*?</\\1?service>`,
'g'
);

return wsdl.replace(serviceRe, (serviceBlock) => {
const portRe = new RegExp(
`\\s*<(${NAME}:)?port\\b[^>]*(?:/>|>[\\s\\S]*?</\\1?port>)`,
'g'
);
const ports = serviceBlock.match(portRe);
if (!ports || ports.length <= 1) {
return serviceBlock;
}

logger.debug(
`WSDL normalizer: keeping first of ${ports.length} wsdl:port endpoints`
);
let first = true;
return serviceBlock.replace(portRe, (port) => {
if (first) {
first = false;
return port;
}
return '';
});
});
}

/** Parse `xmlns:prefix="ns"` declarations from a single tag string. */
function parseXmlnsDeclarations(tag: string): Map<string, string> { const map = new Map<string, string>();
for (const m of tag.matchAll(new RegExp(`xmlns:(${NAME})="([^"]*)"`, 'g'))) {
map.set(m[1], m[2]);
}
return map;
}

/**
* Collect global (top-level) `xs:element` declarations from every inline
* schema, keyed by element name → set of schema targetNamespaces.
* Uses a depth-tracking tag scanner so nested local elements are ignored.
*/
function collectGlobalSchemaElements(wsdl: string): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
const tagRe = /<!--[\s\S]*?-->|<[^>]+>/g;

let schemaNs: string | undefined;
let depth = 0; // open-ancestor count relative to the current schema

for (const m of wsdl.matchAll(tagRe)) {
const raw = m[0];
if (raw.startsWith('<!--') || raw.startsWith('<?') || raw.startsWith('<![')) {
continue;
}

const isClose = raw.startsWith('</');
const isSelfClosing = raw.endsWith('/>');
const nameMatch = new RegExp(`^</?(?:${NAME}:)?(${NAME})`).exec(raw);
if (!nameMatch) {
continue;
}
const localName = nameMatch[1];

if (schemaNs === undefined) {
if (!isClose && !isSelfClosing && localName === 'schema') {
schemaNs = /targetNamespace="([^"]*)"/.exec(raw)?.[1];
depth = 1;
}
continue;
}

if (isClose) {
depth--;
if (depth === 0) {
schemaNs = undefined;
}
continue;
}

// depth === 1 → direct child of the schema, i.e. a global declaration
if (localName === 'element' && depth === 1 && schemaNs) {
const name = /\bname="([^"]*)"/.exec(raw)?.[1];
if (name) {
let set = map.get(name);
if (!set) {
set = new Set<string>();
map.set(name, set);
}
set.add(schemaNs);
}
}

if (!isSelfClosing) {
depth++;
}
}

return map;
}
10 changes: 9 additions & 1 deletion src/services/api-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { redactAndWarnPolicySecrets } from './secret-redactor.js';
import { logger } from '../lib/logger.js';
import { buildResourceLabel } from '../lib/resource-uri.js';
import { getNamePart } from '../lib/resource-path.js';
import { normalizeWsdl } from '../lib/wsdl-normalizer.js';
import { isWorkspaceScope, extractNameFromLink } from '../lib/workspace-link.js';

/**
Expand Down Expand Up @@ -313,10 +314,17 @@ async function extractApiSpecification(
return { extracted: false, errorCount: 0 };
}

// APIM's WSDL export can emit wsdl:part references qualified with the wrong
// namespace prefix and multiple service ports, which its own importer then
// rejects. Normalize so the extracted artifact round-trips through publish.
const content = spec.format === 'wsdl'
? normalizeWsdl(spec.content)
: spec.content;

await store.writeContent(
outputDir,
apiDescriptor,
spec.content,
content,
'specification',
spec.format
);
Expand Down
Loading