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
18 changes: 16 additions & 2 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
): Record<string, unknown> {
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 46 additions & 1 deletion src/lib/wsdl-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
}

/**
Expand Down Expand Up @@ -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<string>();
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<string, string> { const map = new Map<string, string>();
for (const m of tag.matchAll(new RegExp(`xmlns:(${NAME})="([^"]*)"`, 'g'))) {
Expand Down
8 changes: 6 additions & 2 deletions src/services/api-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 = {
Expand Down
52 changes: 52 additions & 0 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, descriptor.workspace, config.envMapping);
}

if (descriptor.type === ResourceType.Api) {
json = normalizeApiVersionSetId(
json,
Expand Down Expand Up @@ -1271,6 +1280,49 @@ 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". Workspace diagnostics reference workspace
* loggers, so the deployed workspace segment is included when present.
*/
export function normalizeDiagnosticLoggerId(
json: Record<string, unknown>,
context: ApimServiceContext,
workspace?: string,
envMapping?: EnvMapping
): Record<string, unknown> {
const props = json.properties as Record<string, unknown> | 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;
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: targetLoggerId },
};
}

export function normalizeApiVersionSetId(
json: Record<string, unknown>,
context: ApimServiceContext,
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/clients/apim-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/lib/wsdl-normalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -159,6 +160,53 @@ describe('normalizeWsdlServicePorts', () => {
});
});

describe('normalizeWsdlXsdImportLocations', () => {
const wsdlWithExternalImports = `<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">
<wsdl:types>
<xs:schema targetNamespace="https://example.org/models/v1" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="Thing" type="xs:string" />
</xs:schema>
<xs:schema targetNamespace="https://example.org/service/v1" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:import schemaLocation="http://192.168.0.10:8080/Svc.svc?xsd=xsd1" namespace="https://example.org/models/v1" />
<xs:import schemaLocation="http://192.168.0.10:8080/Svc.svc?xsd=xsd9" namespace="https://example.org/external/v1" />
</xs:schema>
</wsdl:types>
</wsdl:definitions>`;

it('drops schemaLocation when the imported namespace is declared inline', () => {
const result = normalizeWsdlXsdImportLocations(wsdlWithExternalImports);

expect(result).not.toContain('xsd=xsd1');
expect(result).toContain('<xs:import namespace="https://example.org/models/v1" />');
});

it('keeps schemaLocation when the imported namespace is NOT declared inline', () => {
const result = normalizeWsdlXsdImportLocations(wsdlWithExternalImports);

expect(result).toContain(
'<xs:import schemaLocation="http://192.168.0.10:8080/Svc.svc?xsd=xsd9" namespace="https://example.org/external/v1" />'
);
});

it('leaves wsdl:import (location=) and location-free imports untouched', () => {
const wsdl = `<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/">
<wsdl:import location="http://192.168.0.10/other.wsdl" namespace="https://example.org/other" />
<wsdl:types>
<xs:schema targetNamespace="https://example.org/models/v1" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:import namespace="https://example.org/other" />
</xs:schema>
</wsdl:types>
</wsdl:definitions>`;

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(
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/services/resource-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
publishResource,
resolveAssociationDeleteDescriptor,
normalizeApiAuthenticationSettings,
normalizeDiagnosticLoggerId,
prefersLegacyAuthOverride,
} from '../../../src/services/resource-publisher.js';
import { ResourceType } from '../../../src/models/resource-types.js';
Expand Down Expand Up @@ -2012,6 +2013,62 @@ 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<string, unknown>).loggerId).toBe(
'/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/loggers/my-logger'
);
expect((result.properties as Record<string, unknown>).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, undefined, envMapping);

expect((result.properties as Record<string, unknown>).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<string, unknown>).loggerId).toBe(
'/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1/workspaces/ws-1/loggers/my-logger'
);
});
});

describe('normalizeApiAuthenticationSettings', () => {
it('keeps collections and drops singular fields when collections are non-empty', () => {
const json = {
Expand Down
Loading