From 567570601fff398e8bb47ec8e29c3eedaa514076 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Mon, 14 Sep 2026 16:36:37 +0000 Subject: [PATCH 1/2] fix: enable cross-service publish and WSDL re-import with external xsd:imports Three publish fixes verified against a live Developer-tier instance: - strip the top-level source ARM id from PUT/PATCH bodies; newer APIM api-versions reject cross-service resource references, breaking publish to any service other than the one extracted from - normalize diagnostic loggerId (service and API scope) to the target service ARM path, honoring env-mapping affixes - drop xsd:import schemaLocation attributes that reference external hosts when the imported namespace is declared inline in wsdl:types; APIM's importer otherwise fails ("Unable to parse WSDL") or times out trying to resolve unreachable source-service URLs. Applied on both the publish path (existing artifacts) and extract path (future extracts) Closes #284 --- src/clients/apim-client.ts | 18 ++++++- src/lib/wsdl-normalizer.ts | 47 +++++++++++++++++- src/services/api-publisher.ts | 8 +++- src/services/resource-publisher.ts | 42 ++++++++++++++++ tests/unit/clients/apim-client.test.ts | 30 ++++++++++++ tests/unit/lib/wsdl-normalizer.test.ts | 48 +++++++++++++++++++ .../unit/services/resource-publisher.test.ts | 42 ++++++++++++++++ 7 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/clients/apim-client.ts b/src/clients/apim-client.ts index e508483d..5220bd43 100644 --- a/src/clients/apim-client.ts +++ b/src/clients/apim-client.ts @@ -62,6 +62,20 @@ export function isAssociationReferenceNotFoundError(error: unknown): boolean { }); } +/** + * Drop the top-level ARM `id` from a write payload. Extracted artifacts carry + * the SOURCE service's resource id; newer APIM api-versions reject bodies whose + * id points at a different service ("Cross-service resource references are not + * allowed"), which breaks publishing to any service other than the one extracted from. + */ +export function stripSourceArmId( + payload: Record +): Record { + if (!Object.hasOwn(payload, 'id')) return payload; + const { id: _omit, ...rest } = payload; + return rest; +} + export class ApimClient implements IApimClient { private credential: DefaultAzureCredential; private readonly authScope: string; @@ -415,7 +429,7 @@ export class ApimClient implements IApimClient { const response = await this.request(url, { method: 'PUT', - body: JSON.stringify(payload), + body: JSON.stringify(stripSourceArmId(payload)), }); // Poll for long-running operations signaled by ARM response headers, including @@ -466,7 +480,7 @@ export class ApimClient implements IApimClient { const response = await this.request(url, { method: 'PATCH', - body: JSON.stringify(payload), + body: JSON.stringify(stripSourceArmId(payload)), }); const responseText = await response.text(); diff --git a/src/lib/wsdl-normalizer.ts b/src/lib/wsdl-normalizer.ts index 1c242467..fbd566c6 100644 --- a/src/lib/wsdl-normalizer.ts +++ b/src/lib/wsdl-normalizer.ts @@ -25,7 +25,9 @@ const NAME = String.raw`[\w.-]+`; * publish round trip. */ export function normalizeWsdl(wsdl: string): string { - return normalizeWsdlServicePorts(normalizeWsdlPartReferences(wsdl)); + return normalizeWsdlXsdImportLocations( + normalizeWsdlServicePorts(normalizeWsdlPartReferences(wsdl)) + ); } /** @@ -147,6 +149,49 @@ export function normalizeWsdlServicePorts(wsdl: string): string { }); } +/** + * APIM's WSDL export preserves `xs:import schemaLocation="..."` URLs from the + * original service (often private hosts unreachable from Azure). On re-import + * APIM tries to resolve them and fails or times out, even though every + * imported namespace is declared by an inline schema in `wsdl:types`. Drop the + * schemaLocation attribute from imports whose namespace is available inline. + */ +export function normalizeWsdlXsdImportLocations(wsdl: string): string { + const inlineNamespaces = new Set(); + const schemaRe = new RegExp( + `<(?:${NAME}:)?schema\\b[^>]*\\btargetNamespace="([^"]*)"`, + 'g' + ); + for (const m of wsdl.matchAll(schemaRe)) { + inlineNamespaces.add(m[1]); + } + if (inlineNamespaces.size === 0) { + return wsdl; + } + + let removed = 0; + const importRe = new RegExp(`<(?:${NAME}:)?import\\b[^>]*>`, 'g'); + const rewritten = wsdl.replace(importRe, (tag) => { + const location = /\s*\bschemaLocation="[^"]*"/.exec(tag); + if (!location) { + return tag; // wsdl:import (location=) or already location-free + } + const ns = /\bnamespace="([^"]*)"/.exec(tag); + if (!ns || !inlineNamespaces.has(ns[1])) { + return tag; // namespace not provably available inline — keep untouched + } + removed++; + return tag.replace(location[0], ''); + }); + + if (removed > 0) { + logger.debug( + `WSDL normalizer: removed ${removed} xsd:import schemaLocation attribute(s) resolved by inline schemas` + ); + } + return rewritten; +} + /** Parse `xmlns:prefix="ns"` declarations from a single tag string. */ function parseXmlnsDeclarations(tag: string): Map { const map = new Map(); for (const m of tag.matchAll(new RegExp(`xmlns:(${NAME})="([^"]*)"`, 'g'))) { diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index e2b42a65..f881de7c 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -32,6 +32,7 @@ import { getResourceDescriptorKey, } from '../lib/resource-path.js'; import { isAutoGeneratedId, isPortalGeneratedSchemaId } from '../lib/auto-generated.js'; +import { normalizeWsdlXsdImportLocations } from '../lib/wsdl-normalizer.js'; import { resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js'; /** @@ -588,8 +589,11 @@ async function publishRootApi( cleanProps.apiType = apiType; } - // Sanitize the spec before import (e.g. inject missing path parameters) - const sanitizedContent = sanitizeOpenApiSpec(specResult.content, specResult.format, descriptor); + // Sanitize the spec before import (e.g. inject missing path parameters; + // for WSDL, strip unresolvable external xsd:import schemaLocations). + const sanitizedContent = importFormat === 'wsdl' + ? normalizeWsdlXsdImportLocations(specResult.content) + : sanitizeOpenApiSpec(specResult.content, specResult.format, descriptor); // Inject the spec into the PUT body for APIM to import json = { diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index efd39fe2..6ece78d2 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -442,6 +442,15 @@ export async function publishResource( json = normalizeApiReleaseApiId(json, context, config.envMapping); } + // Diagnostics reference their logger via a full source-service ARM path; + // rebuild it against the target service or APIM rejects the PUT. + if ( + descriptor.type === ResourceType.Diagnostic || + descriptor.type === ResourceType.ApiDiagnostic + ) { + json = normalizeDiagnosticLoggerId(json, context, config.envMapping); + } + if (descriptor.type === ResourceType.Api) { json = normalizeApiVersionSetId( json, @@ -1271,6 +1280,39 @@ function normalizeApiReleaseApiId( return json; } +/** + * Rebuild `properties.loggerId` of a Diagnostic against the target service. + * APIM stores loggerId as the source service's full ARM path; a PUT whose + * loggerId points at another service fails with "Cross-service resource + * references are not allowed". + */ +export function normalizeDiagnosticLoggerId( + json: Record, + context: ApimServiceContext, + envMapping?: EnvMapping +): Record { + const props = json.properties as Record | undefined; + const loggerId = props?.loggerId; + if (typeof loggerId !== 'string') { + return json; + } + + const loggerName = getArmResourceName(loggerId); + if (!loggerName) { + return json; + } + + const targetArmPrefix = context.baseUrl.replace(/^https?:\/\/[^/]+/, ''); + const deployedLogger = envMapping + ? toDeployedName(loggerName, ResourceType.Logger, envMapping) + : loggerName; + + return { + ...json, + properties: { ...props, loggerId: `${targetArmPrefix}/loggers/${deployedLogger}` }, + }; +} + export function normalizeApiVersionSetId( json: Record, context: ApimServiceContext, diff --git a/tests/unit/clients/apim-client.test.ts b/tests/unit/clients/apim-client.test.ts index 9a2a2ef4..ab71dd0d 100644 --- a/tests/unit/clients/apim-client.test.ts +++ b/tests/unit/clients/apim-client.test.ts @@ -547,6 +547,36 @@ describe('ApimClient.putResource provisioning polling', () => { const descriptor = { type: ResourceType.NamedValue, nameParts: ['my-nv'] }; const succeededResource = { name: 'my-nv', properties: { provisioningState: 'Succeeded' } }; + it('should strip the top-level source ARM id from the PUT body', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse(200, succeededResource)); + + await client.putResource(testContext, descriptor, { + id: '/subscriptions/other-sub/resourceGroups/other-rg/providers/Microsoft.ApiManagement/service/other-svc/namedValues/my-nv', + type: 'Microsoft.ApiManagement/service/namedValues', + name: 'my-nv', + properties: { displayName: 'my-nv', value: 'v' }, + }); + + const sentBody = JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string); + expect(sentBody).toEqual({ + type: 'Microsoft.ApiManagement/service/namedValues', + name: 'my-nv', + properties: { displayName: 'my-nv', value: 'v' }, + }); + }); + + it('should strip the top-level source ARM id from the PATCH body', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse(200, succeededResource)); + + await client.patchResource(testContext, descriptor, { + id: '/subscriptions/other-sub/resourceGroups/other-rg/providers/Microsoft.ApiManagement/service/other-svc/namedValues/my-nv', + properties: { value: 'v' }, + }); + + const sentBody = JSON.parse(fetchSpy.mock.calls[0]?.[1]?.body as string); + expect(sentBody).toEqual({ properties: { value: 'v' } }); + }); + it('should poll Azure-AsyncOperation when an update returns 200', async () => { const operationUrl = 'https://management.azure.com/operations/update-named-value'; fetchSpy diff --git a/tests/unit/lib/wsdl-normalizer.test.ts b/tests/unit/lib/wsdl-normalizer.test.ts index 7d6ca86e..112c3d03 100644 --- a/tests/unit/lib/wsdl-normalizer.test.ts +++ b/tests/unit/lib/wsdl-normalizer.test.ts @@ -9,6 +9,7 @@ import { normalizeWsdl, normalizeWsdlPartReferences, normalizeWsdlServicePorts, + normalizeWsdlXsdImportLocations, } from '../../../src/lib/wsdl-normalizer.js'; /** Reproduces APIM's broken WSDL export: parts reference tns instead of ns1. */ @@ -159,6 +160,53 @@ describe('normalizeWsdlServicePorts', () => { }); }); +describe('normalizeWsdlXsdImportLocations', () => { + const wsdlWithExternalImports = ` + + + + + + + + + +`; + + it('drops schemaLocation when the imported namespace is declared inline', () => { + const result = normalizeWsdlXsdImportLocations(wsdlWithExternalImports); + + expect(result).not.toContain('xsd=xsd1'); + expect(result).toContain(''); + }); + + it('keeps schemaLocation when the imported namespace is NOT declared inline', () => { + const result = normalizeWsdlXsdImportLocations(wsdlWithExternalImports); + + expect(result).toContain( + '' + ); + }); + + it('leaves wsdl:import (location=) and location-free imports untouched', () => { + const wsdl = ` + + + + + + +`; + + expect(normalizeWsdlXsdImportLocations(wsdl)).toBe(wsdl); + }); + + it('returns content without inline schemas unchanged', () => { + const openapi = '{"openapi":"3.0.1","info":{"title":"x"}}'; + expect(normalizeWsdlXsdImportLocations(openapi)).toBe(openapi); + }); +}); + describe('normalizeWsdl', () => { it('applies both part-reference and service-port normalizations', () => { const broken = buildBrokenWsdl().replace( diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 6add0063..70f605cc 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -9,6 +9,7 @@ import { publishResource, resolveAssociationDeleteDescriptor, normalizeApiAuthenticationSettings, + normalizeDiagnosticLoggerId, prefersLegacyAuthOverride, } from '../../../src/services/resource-publisher.js'; import { ResourceType } from '../../../src/models/resource-types.js'; @@ -2012,6 +2013,47 @@ describe('resource-publisher', () => { }); }); + describe('normalizeDiagnosticLoggerId', () => { + it('rewrites a source-service loggerId to the target service ARM path', () => { + const json = { + name: 'applicationinsights', + properties: { + alwaysLog: 'allErrors', + loggerId: + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-svc/loggers/my-logger', + }, + }; + + const result = normalizeDiagnosticLoggerId(json, testContext); + + expect((result.properties as Record).loggerId).toBe( + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/loggers/my-logger' + ); + expect((result.properties as Record).alwaysLog).toBe('allErrors'); + }); + + it('returns json unchanged when loggerId is absent', () => { + const json = { name: 'azuremonitor', properties: { alwaysLog: 'allErrors' } }; + expect(normalizeDiagnosticLoggerId(json, testContext)).toBe(json); + }); + + it('applies env-mapping affixes to the logger name', () => { + const envMapping = buildEnvMapping({ nameSuffix: '-dev' }); + const json = { + properties: { + loggerId: + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-svc/loggers/my-logger', + }, + }; + + const result = normalizeDiagnosticLoggerId(json, testContext, envMapping); + + expect((result.properties as Record).loggerId).toBe( + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/loggers/my-logger-dev' + ); + }); + }); + describe('normalizeApiAuthenticationSettings', () => { it('keeps collections and drops singular fields when collections are non-empty', () => { const json = { From fccaded1e4f668b445b761212cd03bdad362ee74 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Mon, 14 Sep 2026 17:03:17 +0000 Subject: [PATCH 2/2] fix: include workspace segment when rebuilding diagnostic loggerId Review finding on #285: workspace-scoped diagnostics reference workspace loggers, but the rebuilt loggerId always pointed at the service level. Pass descriptor.workspace through and emit /workspaces/{ws}/loggers/{name} (with env-mapping affixes), mirroring normalizeApiVersionSetId. --- src/services/resource-publisher.ts | 16 +++++++++++++--- tests/unit/services/resource-publisher.test.ts | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 6ece78d2..9e3c1ef1 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -448,7 +448,7 @@ export async function publishResource( descriptor.type === ResourceType.Diagnostic || descriptor.type === ResourceType.ApiDiagnostic ) { - json = normalizeDiagnosticLoggerId(json, context, config.envMapping); + json = normalizeDiagnosticLoggerId(json, context, descriptor.workspace, config.envMapping); } if (descriptor.type === ResourceType.Api) { @@ -1284,11 +1284,13 @@ function normalizeApiReleaseApiId( * Rebuild `properties.loggerId` of a Diagnostic against the target service. * APIM stores loggerId as the source service's full ARM path; a PUT whose * loggerId points at another service fails with "Cross-service resource - * references are not allowed". + * references are not allowed". Workspace diagnostics reference workspace + * loggers, so the deployed workspace segment is included when present. */ export function normalizeDiagnosticLoggerId( json: Record, context: ApimServiceContext, + workspace?: string, envMapping?: EnvMapping ): Record { const props = json.properties as Record | undefined; @@ -1306,10 +1308,18 @@ export function normalizeDiagnosticLoggerId( const deployedLogger = envMapping ? toDeployedName(loggerName, ResourceType.Logger, envMapping) : loggerName; + const deployedWorkspace = workspace + ? envMapping + ? toDeployedName(workspace, ResourceType.Workspace, envMapping) + : workspace + : undefined; + const targetLoggerId = deployedWorkspace + ? `${targetArmPrefix}/workspaces/${deployedWorkspace}/loggers/${deployedLogger}` + : `${targetArmPrefix}/loggers/${deployedLogger}`; return { ...json, - properties: { ...props, loggerId: `${targetArmPrefix}/loggers/${deployedLogger}` }, + properties: { ...props, loggerId: targetLoggerId }, }; } diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 70f605cc..61c6078e 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -2046,12 +2046,27 @@ describe('resource-publisher', () => { }, }; - const result = normalizeDiagnosticLoggerId(json, testContext, envMapping); + const result = normalizeDiagnosticLoggerId(json, testContext, undefined, envMapping); expect((result.properties as Record).loggerId).toBe( '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/loggers/my-logger-dev' ); }); + + it('includes the workspace segment for workspace-scoped diagnostics', () => { + const json = { + properties: { + loggerId: + '/subscriptions/src-sub/resourceGroups/src-rg/providers/Microsoft.ApiManagement/service/src-svc/workspaces/ws-1/loggers/my-logger', + }, + }; + + const result = normalizeDiagnosticLoggerId(json, testContext, 'ws-1'); + + expect((result.properties as Record).loggerId).toBe( + '/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/workspaces/ws-1/loggers/my-logger' + ); + }); }); describe('normalizeApiAuthenticationSettings', () => {