From 093a77d723d35f77a14f366bbc9c619dcf1da4c0 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Tue, 8 Sep 2026 11:27:21 +0000 Subject: [PATCH 1/3] fix: prevent dry-run crash on gateway API association descriptors buildResourceLabel() always formatted labels from armPathSuffix, which has more placeholders than a parent-level association descriptor (e.g. GatewayApi discovered from gateways/{gateway}/apis.json with one name-part) can fill. The resulting throw escaped from logger.info inside the dry-run loop and aborted the entire publish with a fatal error. Fall back to the artifactDirectory template when the descriptor has fewer name-parts than armPathSuffix placeholders. --- src/lib/resource-uri.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/resource-uri.ts b/src/lib/resource-uri.ts index a0c24033..bd6b3a0a 100644 --- a/src/lib/resource-uri.ts +++ b/src/lib/resource-uri.ts @@ -130,6 +130,17 @@ export function parseArmUri( */ export function buildResourceLabel(descriptor: ResourceDescriptor): string { const metadata = RESOURCE_TYPE_METADATA[descriptor.type]; + + // Association types (e.g. GatewayApi) are sometimes discovered at the + // parent-association-file level — a descriptor carrying only the parent's + // name-parts (e.g. GatewayApi ['my-gateway'] representing + // gateways/my-gateway/apis.json before per-API entries are expanded). + // armPathSuffix has more placeholders than such a descriptor can fill, so + // fall back to artifactDirectory (which only needs the parent's parts). + if (descriptor.nameParts.length < countTemplatePlaceholders(metadata.armPathSuffix)) { + return formatTemplatePath(metadata.artifactDirectory, descriptor.nameParts); + } + // armPathSuffix has no leading slash, so the result is already relative return formatTemplatePath(metadata.armPathSuffix, descriptor.nameParts); } From 3271daa464c3fff30c14cec11e8be35907b7b2b2 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Tue, 8 Sep 2026 11:27:40 +0000 Subject: [PATCH 2/3] fix: skip WSDL-importer-generated XSD schemas during publish APIM recreates XSD schemas itself when a WSDL spec is imported, but the isAutoGeneratedId() filter only matches 24-char hex IDs while the WSDL importer names XSD schemas with GUIDs. The CLI therefore re-published them as explicit schemas and APIM rejected every PUT with ValidationError (25 errors across 7 SOAP APIs in a real-world export). Exclude schemas whose contentType is application/vnd.ms-azure-apim.xsd+xml from childPuts when the specification is being imported. Content-type filtering is safer than widening the auto-generated ID pattern to GUIDs, since user-defined schemas may legitimately use GUID names. --- src/services/api-publisher.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index d78917ab..e2ff874f 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -321,15 +321,28 @@ export async function planApiPublication( descriptor.workspace === apiDescriptor.workspace && !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) ); + // WSDL-import-generated XSD schemas have GUID names (not 24-hex), so the + // isAutoGeneratedId filter misses them. APIM recreates them on WSDL import + // and rejects re-PUTs, so exclude schemas with the XSD content type. + const nonImporterSchemas = ( + await Promise.all( + explicitSchemas.map(async (descriptor) => { + const schemaJson = await store.readResource(config.sourceDir, descriptor); + const contentType = (schemaJson?.properties as Record | undefined) + ?.contentType; + return contentType === 'application/vnd.ms-azure-apim.xsd+xml' ? undefined : descriptor; + }) + ) + ).filter((descriptor): descriptor is ResourceDescriptor => descriptor !== undefined); const filteredExplicitSchemas = config.filter - ? explicitSchemas.filter((descriptor) => + ? nonImporterSchemas.filter((descriptor) => shouldIncludeResource(descriptor, effectiveFilter) ) : allowed - ? explicitSchemas.filter((descriptor) => + ? nonImporterSchemas.filter((descriptor) => allowed.has(getResourceDescriptorKey(descriptor)) ) - : explicitSchemas; + : nonImporterSchemas; childPuts = [...childPuts, ...filteredExplicitSchemas]; } From e061ba4b7260338782a3157969e58e93fd56a183 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Tue, 8 Sep 2026 11:44:48 +0000 Subject: [PATCH 3/3] fix: limit XSD schema exclusion to WSDL imports and add review-requested tests Address PR review feedback: - Only exclude XSD-content-type schemas when the imported spec format is wsdl; other importers (OpenAPI, Swagger, WADL) do not recreate them, so explicitly managed XSD schemas are retained there. - Add regression tests: GatewayApi label fallback for one-part descriptors (gateways/my-gateway) and two-part expansion; XSD/GUID schema skipped on WSDL import but retained on OpenAPI import. --- src/services/api-publisher.ts | 27 +++++---- tests/unit/lib/resource-uri.test.ts | 20 +++++++ tests/unit/services/api-publisher.test.ts | 69 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index e2ff874f..da237f75 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -276,6 +276,7 @@ export async function planApiPublication( ); let importSpecification = false; + let importSpecificationFormat: string | undefined; let operationDescriptionPuts: ResourceDescriptor[] = []; if (specificationAllowed) { const specification = await store.readContent(config.sourceDir, apiDescriptor, 'specification'); @@ -285,6 +286,7 @@ export async function planApiPublication( const dialect = detectSpecDialect(specification.content, specification.format); importSpecification = getImportFormat(specification.format ?? 'yaml', apiType, dialect) !== undefined; + importSpecificationFormat = importSpecification ? specification.format : undefined; if (importSpecification) { operationDescriptionPuts = getOpenApiOperationIdsWithNullDescription( specification.content, @@ -323,17 +325,20 @@ export async function planApiPublication( ); // WSDL-import-generated XSD schemas have GUID names (not 24-hex), so the // isAutoGeneratedId filter misses them. APIM recreates them on WSDL import - // and rejects re-PUTs, so exclude schemas with the XSD content type. - const nonImporterSchemas = ( - await Promise.all( - explicitSchemas.map(async (descriptor) => { - const schemaJson = await store.readResource(config.sourceDir, descriptor); - const contentType = (schemaJson?.properties as Record | undefined) - ?.contentType; - return contentType === 'application/vnd.ms-azure-apim.xsd+xml' ? undefined : descriptor; - }) - ) - ).filter((descriptor): descriptor is ResourceDescriptor => descriptor !== undefined); + // and rejects re-PUTs, so exclude schemas with the XSD content type — but + // only for WSDL imports; other importers do not recreate XSD schemas. + const nonImporterSchemas = importSpecificationFormat !== 'wsdl' + ? explicitSchemas + : ( + await Promise.all( + explicitSchemas.map(async (descriptor) => { + const schemaJson = await store.readResource(config.sourceDir, descriptor); + const contentType = (schemaJson?.properties as Record | undefined) + ?.contentType; + return contentType === 'application/vnd.ms-azure-apim.xsd+xml' ? undefined : descriptor; + }) + ) + ).filter((descriptor): descriptor is ResourceDescriptor => descriptor !== undefined); const filteredExplicitSchemas = config.filter ? nonImporterSchemas.filter((descriptor) => shouldIncludeResource(descriptor, effectiveFilter) diff --git a/tests/unit/lib/resource-uri.test.ts b/tests/unit/lib/resource-uri.test.ts index 5df4dc29..60674f8a 100644 --- a/tests/unit/lib/resource-uri.test.ts +++ b/tests/unit/lib/resource-uri.test.ts @@ -251,6 +251,26 @@ describe('buildResourceLabel', () => { expect(label).toBe('apis/petstore/operations/get-user'); }); + it('should fall back to artifact directory for parent-level association descriptor (GatewayApi)', () => { + // GatewayApi discovered from gateways/{gateway}/apis.json carries only the + // gateway name; armPathSuffix needs two parts and used to throw fatally. + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway'], + }; + const label = buildResourceLabel(descriptor); + expect(label).toBe('gateways/my-gateway'); + }); + + it('should format fully-expanded association descriptor (GatewayApi)', () => { + const descriptor: ResourceDescriptor = { + type: ResourceType.GatewayApi, + nameParts: ['my-gateway', 'my-api'], + }; + const label = buildResourceLabel(descriptor); + expect(label).toBe('gateways/my-gateway/apis/my-api'); + }); + it('should format grandchild policy resource (ApiOperationPolicy)', () => { const descriptor: ResourceDescriptor = { type: ResourceType.ApiOperationPolicy, diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 761a3296..d8bd3306 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -1894,6 +1894,75 @@ describe('api-publisher', () => { expect(totalTasks).toBe(1); }); + it('should skip WSDL-importer-generated XSD schemas (GUID names) on WSDL import', async () => { + const client = createMockClient(); + // GUID name is not caught by isAutoGeneratedId (24-hex only), but the + // WSDL importer recreates XSD schemas, so re-PUTs must be excluded. + const xsdSchema = { + type: ResourceType.ApiSchema, + nameParts: ['soap-api', '4b6fe4d6-dbad-4db0-a6d6-58958e74850d'], + }; + const store = createMockStore([xsdSchema]); + store.readResource.mockImplementation(async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Api) { + return { name: 'soap-api', properties: { path: 'soap', type: 'soap' } }; + } + if (descriptor.type === ResourceType.ApiSchema) { + return { + name: descriptor.nameParts[1], + properties: { contentType: 'application/vnd.ms-azure-apim.xsd+xml', document: { value: '' } }, + }; + } + return null; + }); + store.readContent.mockResolvedValue({ + content: '...', + format: 'wsdl', + }); + + const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['soap-api'] }; + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + const totalTasks = mockRunParallel.mock.calls.reduce((sum, call) => { + const tasks = call[0] as unknown[]; + return sum + tasks.length; + }, 0); + expect(totalTasks).toBe(0); + }); + + it('should retain GUID-named XSD schemas for non-WSDL spec imports', async () => { + const client = createMockClient(); + // OpenAPI import does not recreate XSD schemas, so the exclusion must + // apply only when the imported spec format is wsdl. + const xsdSchema = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '4b6fe4d6-dbad-4db0-a6d6-58958e74850d'], + }; + const store = createMockStore([xsdSchema]); + store.readResource.mockImplementation(async (_dir: string, descriptor: ResourceDescriptor) => { + if (descriptor.type === ResourceType.Api) { + return { name: 'rest-api', properties: { path: 'rest' } }; + } + if (descriptor.type === ResourceType.ApiSchema) { + return { + name: descriptor.nameParts[1], + properties: { contentType: 'application/vnd.ms-azure-apim.xsd+xml', document: { value: '' } }, + }; + } + return null; + }); + store.readContent.mockResolvedValue({ content: 'openapi: "3.0.0"', format: 'yaml' }); + + const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['rest-api'] }; + await publishApi(client, store, testContext, apiDescriptor, testConfig); + + const totalTasks = mockRunParallel.mock.calls.reduce((sum, call) => { + const tasks = call[0] as unknown[]; + return sum + tasks.length; + }, 0); + expect(totalTasks).toBe(1); + }); + it('should reconcile operations via PATCH even in incremental mode (commitId set)', async () => { mockRunParallel.mockImplementation(async (tasks: Array<() => Promise>) => { for (const task of tasks) await task();