From 5cb17b16f399ef07b5e8387104df6639b980ea45 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Tue, 8 Sep 2026 16:45:18 +0000 Subject: [PATCH 1/6] fix: treat 13-digit epoch-millis schema IDs as auto-generated Newer APIM API versions (e.g. 2025-09-01-preview) assign epoch-millis IDs (e.g. 1786466527403) to schemas created on spec import, but isAutoGeneratedId() only matched 24-char hex IDs. The CLI therefore re-published such schemas after the spec import, creating a duplicate schema on the destination API. Extend the auto-generated ID detection with a 13-digit numeric pattern so these schemas are skipped on publish, like their 24-hex predecessors. Closes #274 --- src/lib/auto-generated.ts | 18 +++++++++++++++--- tests/unit/lib/auto-generated.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/lib/auto-generated.ts b/src/lib/auto-generated.ts index 970b2f00..3efd0fb7 100644 --- a/src/lib/auto-generated.ts +++ b/src/lib/auto-generated.ts @@ -18,20 +18,32 @@ const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/; /** - * Checks if a resource name/ID is an auto-generated 24-character hex ID. + * Pattern matching the newer epoch-milliseconds schema IDs that APIM assigns + * on spec import with recent API versions (e.g. 2025-09-01-preview). + * Example: "1786466527403" + */ +const AUTO_GENERATED_TIMESTAMP_ID_PATTERN = /^\d{13}$/; + +/** + * Checks if a resource name/ID is an auto-generated APIM ID. * * APIM auto-generates these IDs for: * - ApiSchema resources when importing OpenAPI/WSDL specifications + * (24-char hex, or 13-digit epoch-millis on newer API versions) * - NamedValue resources for logger credentials (EventHub, etc.) * * @param name - The resource name or ID to check - * @returns true if the name matches the auto-generated pattern + * @returns true if the name matches an auto-generated pattern * * @example * isAutoGeneratedId('69f15c3c10a45d29d855583a') // true + * isAutoGeneratedId('1786466527403') // true * isAutoGeneratedId('src-rest-schema-item') // false * isAutoGeneratedId('my-named-value') // false */ export function isAutoGeneratedId(name: string): boolean { - return AUTO_GENERATED_ID_PATTERN.test(name); + return ( + AUTO_GENERATED_ID_PATTERN.test(name) || + AUTO_GENERATED_TIMESTAMP_ID_PATTERN.test(name) + ); } diff --git a/tests/unit/lib/auto-generated.test.ts b/tests/unit/lib/auto-generated.test.ts index 26e39efb..c59c38f6 100644 --- a/tests/unit/lib/auto-generated.test.ts +++ b/tests/unit/lib/auto-generated.test.ts @@ -12,6 +12,17 @@ describe('auto-generated', () => { expect(isAutoGeneratedId('ffffffffffffffffffffffff')).toBe(true); }); + it('should return true for 13-digit epoch-millis schema IDs (newer APIM versions)', () => { + expect(isAutoGeneratedId('1786466527403')).toBe(true); + expect(isAutoGeneratedId('1700000000000')).toBe(true); + }); + + it('should return false for numeric IDs of other lengths', () => { + expect(isAutoGeneratedId('178646652740')).toBe(false); // 12 digits + expect(isAutoGeneratedId('17864665274031')).toBe(false); // 14 digits + expect(isAutoGeneratedId('1')).toBe(false); + }); + it('should return false for human-readable schema names', () => { expect(isAutoGeneratedId('src-rest-schema-item')).toBe(false); expect(isAutoGeneratedId('my-schema')).toBe(false); From 991038851e10546df530b8a502863a578a146c29 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Tue, 8 Sep 2026 16:58:52 +0000 Subject: [PATCH 2/6] fix: attribute 13-digit schema IDs to portal-created schemas; add publish test Live verification against a Developer-tier instance showed that ARM spec import generates 24-hex schema IDs even on api-version 2025-09-01-preview. The Date.now()-style 13-digit IDs come from schemas created outside spec import (e.g. the portal's OpenAPI spec editor), so reword comments accordingly. Behavior is unchanged: such IDs are machine-generated and re-publishing them after spec import duplicates the schema (#274). Also add the publish-path regression test that skips a 13-digit schema on spec import. --- src/lib/auto-generated.ts | 10 ++++--- tests/unit/lib/auto-generated.test.ts | 2 +- tests/unit/services/api-publisher.test.ts | 36 +++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/lib/auto-generated.ts b/src/lib/auto-generated.ts index 3efd0fb7..53753093 100644 --- a/src/lib/auto-generated.ts +++ b/src/lib/auto-generated.ts @@ -18,8 +18,10 @@ const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/; /** - * Pattern matching the newer epoch-milliseconds schema IDs that APIM assigns - * on spec import with recent API versions (e.g. 2025-09-01-preview). + * Pattern matching epoch-milliseconds schema IDs (Date.now()-style) assigned + * when schemas are created outside spec import, e.g. by the Azure portal's + * OpenAPI specification editor. Verified: ARM spec import generates 24-hex + * IDs even on 2025-09-01-preview. * Example: "1786466527403" */ const AUTO_GENERATED_TIMESTAMP_ID_PATTERN = /^\d{13}$/; @@ -28,8 +30,8 @@ const AUTO_GENERATED_TIMESTAMP_ID_PATTERN = /^\d{13}$/; * Checks if a resource name/ID is an auto-generated APIM ID. * * APIM auto-generates these IDs for: - * - ApiSchema resources when importing OpenAPI/WSDL specifications - * (24-char hex, or 13-digit epoch-millis on newer API versions) + * - ApiSchema resources when importing OpenAPI/WSDL specifications (24-char hex) + * - ApiSchema resources created via the portal spec editor (13-digit epoch-millis) * - NamedValue resources for logger credentials (EventHub, etc.) * * @param name - The resource name or ID to check diff --git a/tests/unit/lib/auto-generated.test.ts b/tests/unit/lib/auto-generated.test.ts index c59c38f6..592edb53 100644 --- a/tests/unit/lib/auto-generated.test.ts +++ b/tests/unit/lib/auto-generated.test.ts @@ -12,7 +12,7 @@ describe('auto-generated', () => { expect(isAutoGeneratedId('ffffffffffffffffffffffff')).toBe(true); }); - it('should return true for 13-digit epoch-millis schema IDs (newer APIM versions)', () => { + it('should return true for 13-digit epoch-millis schema IDs (portal-created schemas)', () => { expect(isAutoGeneratedId('1786466527403')).toBe(true); expect(isAutoGeneratedId('1700000000000')).toBe(true); }); diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 761a3296..8a68fc39 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -1894,6 +1894,42 @@ describe('api-publisher', () => { expect(totalTasks).toBe(1); }); + it('should skip 13-digit epoch-millis schema IDs (portal-created) on spec import', async () => { + const client = createMockClient(); + // The portal spec editor assigns Date.now()-style schema IDs; the spec + // import recreates schema content, so re-PUTs create a duplicate (#274). + const timestampSchema = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '1786466527403'], + }; + const store = createMockStore([timestampSchema]); + 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.oai.openapi.components+json', + document: { components: { schemas: {} } }, + }, + }; + } + 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(0); + }); + 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(); From d34b156cd62b2ca07105c5687e6eda859ab9e1f4 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 11 Sep 2026 17:09:04 +0000 Subject: [PATCH 3/6] refactor: narrow 13-digit schema ID handling to the spec-import schema path Widening isAutoGeneratedId() globally was too risky: the predicate also gates named values, subscriptions, and operation reconciliation, where a 13-digit-named resource would be silently skipped and its data lost. Move the pattern to a dedicated isPortalGeneratedSchemaId() helper applied only when filtering explicit ApiSchema re-publishes during spec import -- the one place where skipping is safe because the import recreates schema content. Also verified live: the portal generates these Date.now() IDs when adding a definition to an API with no schemas (operation Frontend editor), while ARM spec import produces 24-hex IDs on all api-versions 2021-08-01 through 2025-09-01-preview. --- src/lib/auto-generated.ts | 42 ++++++++++++++++++--------- src/services/api-publisher.ts | 8 +++-- tests/unit/lib/auto-generated.test.ts | 36 ++++++++++++++++------- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/lib/auto-generated.ts b/src/lib/auto-generated.ts index 53753093..a35b67a2 100644 --- a/src/lib/auto-generated.ts +++ b/src/lib/auto-generated.ts @@ -18,34 +18,48 @@ const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/; /** - * Pattern matching epoch-milliseconds schema IDs (Date.now()-style) assigned - * when schemas are created outside spec import, e.g. by the Azure portal's - * OpenAPI specification editor. Verified: ARM spec import generates 24-hex - * IDs even on 2025-09-01-preview. + * Pattern matching epoch-milliseconds schema IDs (Date.now()-style). The Azure + * portal generates these client-side when a definition is added to an API that + * has no schema resource yet (operation Frontend editor → "New definition"). + * Verified: ARM spec import generates 24-hex IDs on every api-version + * 2021-08-01 … 2025-09-01-preview, so 13-digit IDs never come from import. * Example: "1786466527403" */ -const AUTO_GENERATED_TIMESTAMP_ID_PATTERN = /^\d{13}$/; +const PORTAL_SCHEMA_ID_PATTERN = /^\d{13}$/; /** - * Checks if a resource name/ID is an auto-generated APIM ID. + * Checks if a resource name/ID is an auto-generated 24-character hex ID. * * APIM auto-generates these IDs for: - * - ApiSchema resources when importing OpenAPI/WSDL specifications (24-char hex) - * - ApiSchema resources created via the portal spec editor (13-digit epoch-millis) + * - ApiSchema resources when importing OpenAPI/WSDL specifications * - NamedValue resources for logger credentials (EventHub, etc.) * * @param name - The resource name or ID to check - * @returns true if the name matches an auto-generated pattern + * @returns true if the name matches the auto-generated pattern * * @example * isAutoGeneratedId('69f15c3c10a45d29d855583a') // true - * isAutoGeneratedId('1786466527403') // true * isAutoGeneratedId('src-rest-schema-item') // false * isAutoGeneratedId('my-named-value') // false */ export function isAutoGeneratedId(name: string): boolean { - return ( - AUTO_GENERATED_ID_PATTERN.test(name) || - AUTO_GENERATED_TIMESTAMP_ID_PATTERN.test(name) - ); + return AUTO_GENERATED_ID_PATTERN.test(name); +} + +/** + * Checks if a schema name is a portal-generated 13-digit epoch-millis ID. + * + * Deliberately separate from isAutoGeneratedId: a 13-digit name is a weaker + * heuristic than 24-hex, so it must only be applied where a false positive is + * harmless — skipping ApiSchema re-publish when a spec import recreates the + * schema content anyway. It must NOT gate named values, subscriptions, or + * operations, where a silently skipped resource would lose data. + * + * @example + * isPortalGeneratedSchemaId('1786466527403') // true + * isPortalGeneratedSchemaId('178646652740') // false (12 digits) + * isPortalGeneratedSchemaId('my-schema') // false + */ +export function isPortalGeneratedSchemaId(name: string): boolean { + return PORTAL_SCHEMA_ID_PATTERN.test(name); } diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index d78917ab..38546752 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -31,7 +31,7 @@ import { getPublishTier, getResourceDescriptorKey, } from '../lib/resource-path.js'; -import { isAutoGeneratedId } from '../lib/auto-generated.js'; +import { isAutoGeneratedId, isPortalGeneratedSchemaId } from '../lib/auto-generated.js'; import { resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js'; /** @@ -319,7 +319,11 @@ export async function planApiPublication( descriptor.type === ResourceType.ApiSchema && getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() && descriptor.workspace === apiDescriptor.workspace && - !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) + !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) && + // Portal-created schemas (13-digit Date.now() names) are recreated by + // the spec import like other machine-generated schemas; re-PUTs + // duplicate them on the destination (#274). + !isPortalGeneratedSchemaId(getNamePart(descriptor.nameParts, 1)) ); const filteredExplicitSchemas = config.filter ? explicitSchemas.filter((descriptor) => diff --git a/tests/unit/lib/auto-generated.test.ts b/tests/unit/lib/auto-generated.test.ts index 592edb53..42f9f713 100644 --- a/tests/unit/lib/auto-generated.test.ts +++ b/tests/unit/lib/auto-generated.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { describe, it, expect } from 'vitest'; -import { isAutoGeneratedId } from '../../../src/lib/auto-generated.js'; +import { isAutoGeneratedId, isPortalGeneratedSchemaId } from '../../../src/lib/auto-generated.js'; describe('auto-generated', () => { describe('isAutoGeneratedId', () => { @@ -12,15 +12,11 @@ describe('auto-generated', () => { expect(isAutoGeneratedId('ffffffffffffffffffffffff')).toBe(true); }); - it('should return true for 13-digit epoch-millis schema IDs (portal-created schemas)', () => { - expect(isAutoGeneratedId('1786466527403')).toBe(true); - expect(isAutoGeneratedId('1700000000000')).toBe(true); - }); - - it('should return false for numeric IDs of other lengths', () => { - expect(isAutoGeneratedId('178646652740')).toBe(false); // 12 digits - expect(isAutoGeneratedId('17864665274031')).toBe(false); // 14 digits - expect(isAutoGeneratedId('1')).toBe(false); + it('should NOT match 13-digit portal schema IDs (handled by isPortalGeneratedSchemaId)', () => { + // 13-digit is a weaker heuristic; it must not gate named values, + // subscriptions, or operations that share this predicate. + expect(isAutoGeneratedId('1786466527403')).toBe(false); + expect(isAutoGeneratedId('1700000000000')).toBe(false); }); it('should return false for human-readable schema names', () => { @@ -51,4 +47,24 @@ describe('auto-generated', () => { expect(isAutoGeneratedId('69f15c3c10a45d29d855583-')).toBe(false); }); }); + + describe('isPortalGeneratedSchemaId', () => { + it('should return true for 13-digit epoch-millis IDs (portal-created schemas)', () => { + expect(isPortalGeneratedSchemaId('1786466527403')).toBe(true); + expect(isPortalGeneratedSchemaId('1789146226316')).toBe(true); + }); + + it('should return false for numeric IDs of other lengths', () => { + expect(isPortalGeneratedSchemaId('178646652740')).toBe(false); // 12 digits + expect(isPortalGeneratedSchemaId('17864665274031')).toBe(false); // 14 digits + expect(isPortalGeneratedSchemaId('1')).toBe(false); + expect(isPortalGeneratedSchemaId('')).toBe(false); + }); + + it('should return false for 24-hex and human-readable names', () => { + expect(isPortalGeneratedSchemaId('69f15c3c10a45d29d855583a')).toBe(false); + expect(isPortalGeneratedSchemaId('my-schema')).toBe(false); + expect(isPortalGeneratedSchemaId('1786466527403x')).toBe(false); + }); + }); }); From 5a7ac2e8ee46fd43d8b2ba1a8ab10ae8047319bd Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 11 Sep 2026 19:30:22 +0000 Subject: [PATCH 4/6] fix: gate 13-digit schema skip on the imported spec recreating its components Address PR review: a 13-digit name alone is a weak provenance signal and would silently drop an explicitly named numeric schema that the spec import does not recreate. The skip now additionally requires that every component defined in the artifact schema document (components.schemas / definitions) is declared in the imported specification. When the spec is not parseable OpenAPI, declares no components, misses any of the schema's components, or the artifact document defines none, the schema is published like any other explicit schema. Tests: skip when spec covers the components; re-publish when components are absent from the spec and when the spec declares no components at all. --- src/services/api-publisher.ts | 85 ++++++++++++++++++-- tests/unit/services/api-publisher.test.ts | 96 +++++++++++++++++++++-- 2 files changed, 170 insertions(+), 11 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index 38546752..d6d0fb49 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 importSpecSchemaNames: Set | undefined; let operationDescriptionPuts: ResourceDescriptor[] = []; if (specificationAllowed) { const specification = await store.readContent(config.sourceDir, apiDescriptor, 'specification'); @@ -286,6 +287,10 @@ export async function planApiPublication( importSpecification = getImportFormat(specification.format ?? 'yaml', apiType, dialect) !== undefined; if (importSpecification) { + importSpecSchemaNames = getSpecComponentSchemaNames( + specification.content, + specification.format + ); operationDescriptionPuts = getOpenApiOperationIdsWithNullDescription( specification.content, specification.format @@ -314,17 +319,39 @@ export async function planApiPublication( } if (importSpecification) { - const explicitSchemas = allDescriptors.filter( + const candidateSchemas = allDescriptors.filter( (descriptor) => descriptor.type === ResourceType.ApiSchema && getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() && descriptor.workspace === apiDescriptor.workspace && - !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) && - // Portal-created schemas (13-digit Date.now() names) are recreated by - // the spec import like other machine-generated schemas; re-PUTs - // duplicate them on the destination (#274). - !isPortalGeneratedSchemaId(getNamePart(descriptor.nameParts, 1)) + !isAutoGeneratedId(getNamePart(descriptor.nameParts, 1)) ); + // Portal-created schemas (13-digit Date.now() names) duplicate on re-PUT + // because the spec import recreates their content (#274). A 13-digit name + // alone is a weak signal, so only skip when the imported spec provably + // carries every component the artifact schema defines; otherwise publish + // it like any explicitly named schema. + const explicitSchemas = ( + await Promise.all( + candidateSchemas.map(async (descriptor) => { + if (!isPortalGeneratedSchemaId(getNamePart(descriptor.nameParts, 1))) { + return descriptor; + } + if (!importSpecSchemaNames || importSpecSchemaNames.size === 0) { + return descriptor; + } + const schemaJson = await store.readResource(config.sourceDir, descriptor); + const componentNames = getArtifactSchemaComponentNames(schemaJson); + if (!componentNames || componentNames.length === 0) { + return descriptor; + } + const recreatedByImport = componentNames.every((name) => + importSpecSchemaNames.has(name) + ); + return recreatedByImport ? undefined : descriptor; + }) + ) + ).filter((descriptor): descriptor is ResourceDescriptor => descriptor !== undefined); const filteredExplicitSchemas = config.filter ? explicitSchemas.filter((descriptor) => shouldIncludeResource(descriptor, effectiveFilter) @@ -1055,6 +1082,52 @@ function detectSpecDialect(content: string, format: string | undefined): ApiSpec } } +/** + * Extracts the set of schema component names declared in an OpenAPI/Swagger + * spec document: `components.schemas` (OpenAPI 3.x) or `definitions` + * (Swagger 2.0). Returns undefined when the content is not a parseable + * OpenAPI/Swagger document (e.g. WSDL/GraphQL) — callers must treat that as + * "no evidence" rather than "no schemas". + */ +function getSpecComponentSchemaNames( + content: string, + format: string | undefined +): Set | undefined { + if (format !== undefined && format !== 'yaml' && format !== 'json') { + return undefined; + } + try { + const doc = yaml.load(content) as Record | undefined; + if (!doc || typeof doc !== 'object') return undefined; + const components = (doc.components as Record | undefined)?.schemas + ?? doc.definitions; + if (components && typeof components === 'object' && !Array.isArray(components)) { + return new Set(Object.keys(components)); + } + return new Set(); + } catch { + return undefined; + } +} + +/** + * Extracts the schema component names defined by an ApiSchema artifact's + * `properties.document` (`components.schemas` or Swagger `definitions`). + */ +function getArtifactSchemaComponentNames( + json: Record | null | undefined +): string[] | undefined { + const props = json?.properties as Record | undefined; + const doc = props?.document as Record | undefined; + if (!doc || typeof doc !== 'object') return undefined; + const components = (doc.components as Record | undefined)?.schemas + ?? doc.definitions; + if (components && typeof components === 'object' && !Array.isArray(components)) { + return Object.keys(components); + } + return undefined; +} + /** * Sanitize an OpenAPI spec before importing into APIM. * Currently handles: diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 8a68fc39..d440cb97 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -1894,10 +1894,10 @@ describe('api-publisher', () => { expect(totalTasks).toBe(1); }); - it('should skip 13-digit epoch-millis schema IDs (portal-created) on spec import', async () => { + it('should skip 13-digit portal schema when the imported spec recreates its components', async () => { const client = createMockClient(); - // The portal spec editor assigns Date.now()-style schema IDs; the spec - // import recreates schema content, so re-PUTs create a duplicate (#274). + // The portal assigns Date.now()-style schema IDs; when the imported spec + // declares every component the schema defines, re-PUTs only duplicate it (#274). const timestampSchema = { type: ResourceType.ApiSchema, nameParts: ['rest-api', '1786466527403'], @@ -1912,13 +1912,21 @@ describe('api-publisher', () => { name: descriptor.nameParts[1], properties: { contentType: 'application/vnd.oai.openapi.components+json', - document: { components: { schemas: {} } }, + document: { components: { schemas: { Item: { type: 'object' } } } }, }, }; } return null; }); - store.readContent.mockResolvedValue({ content: 'openapi: "3.0.0"', format: 'yaml' }); + store.readContent.mockResolvedValue({ + content: JSON.stringify({ + openapi: '3.0.1', + info: { title: 'rest-api', version: '1.0' }, + paths: {}, + components: { schemas: { Item: { type: 'object' } } }, + }), + format: 'json', + }); const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['rest-api'] }; await publishApi(client, store, testContext, apiDescriptor, testConfig); @@ -1930,6 +1938,84 @@ describe('api-publisher', () => { expect(totalTasks).toBe(0); }); + it('should re-publish a 13-digit-named schema whose components are absent from the imported spec', async () => { + const client = createMockClient(); + // Numeric-but-explicit case: the spec does not recreate this schema's + // content, so skipping it would silently lose it on a clean destination. + const timestampSchema = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '1786466527403'], + }; + const store = createMockStore([timestampSchema]); + 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.oai.openapi.components+json', + document: { components: { schemas: { Standalone: { type: 'object' } } } }, + }, + }; + } + return null; + }); + store.readContent.mockResolvedValue({ + content: JSON.stringify({ + openapi: '3.0.1', + info: { title: 'rest-api', version: '1.0' }, + paths: {}, + components: { schemas: { Other: { type: 'object' } } }, + }), + format: 'json', + }); + + 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 re-publish a 13-digit-named schema when the imported spec declares no components', async () => { + const client = createMockClient(); + const timestampSchema = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '1786466527403'], + }; + const store = createMockStore([timestampSchema]); + 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.oai.openapi.components+json', + document: { components: { schemas: { Standalone: { type: 'object' } } } }, + }, + }; + } + 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(); From 0bbd4bcb97280624d1adfb7aacda1cbd0f677307 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 11 Sep 2026 20:03:29 +0000 Subject: [PATCH 5/6] fix: require structural equality of schema components before skipping re-publish Address PR review (two comments): - Compare full component definitions, not just names: a 13-digit-named schema may define a component with the same name but different shape than the spec; name-only matching would drop it on a clean destination. The skip now requires every artifact component to be structurally identical (deep, key-order-insensitive) to the spec's declaration. - Gate artifact component extraction on OpenAPI/Swagger content types: in standalone JSON Schema documents (schemaType: json), 'definitions' has a different meaning and spec import does not recreate the resource; such schemas are always retained. New tests: same-named component with different shape is re-published; standalone JSON Schema with overlapping definition names is re-published. --- src/services/api-publisher.ts | 78 +++++++++++++----- tests/unit/services/api-publisher.test.ts | 98 +++++++++++++++++++++++ 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index d6d0fb49..bdd47429 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -276,7 +276,7 @@ export async function planApiPublication( ); let importSpecification = false; - let importSpecSchemaNames: Set | undefined; + let importSpecSchemaComponents: Record | undefined; let operationDescriptionPuts: ResourceDescriptor[] = []; if (specificationAllowed) { const specification = await store.readContent(config.sourceDir, apiDescriptor, 'specification'); @@ -287,7 +287,7 @@ export async function planApiPublication( importSpecification = getImportFormat(specification.format ?? 'yaml', apiType, dialect) !== undefined; if (importSpecification) { - importSpecSchemaNames = getSpecComponentSchemaNames( + importSpecSchemaComponents = getSpecComponentSchemas( specification.content, specification.format ); @@ -329,24 +329,32 @@ export async function planApiPublication( // Portal-created schemas (13-digit Date.now() names) duplicate on re-PUT // because the spec import recreates their content (#274). A 13-digit name // alone is a weak signal, so only skip when the imported spec provably - // carries every component the artifact schema defines; otherwise publish - // it like any explicitly named schema. + // recreates every component the artifact schema defines — same name AND + // structurally identical definition. Any ambiguity → publish the schema + // like any explicitly named one. const explicitSchemas = ( await Promise.all( candidateSchemas.map(async (descriptor) => { if (!isPortalGeneratedSchemaId(getNamePart(descriptor.nameParts, 1))) { return descriptor; } - if (!importSpecSchemaNames || importSpecSchemaNames.size === 0) { + const specComponents = importSpecSchemaComponents; + if (!specComponents || Object.keys(specComponents).length === 0) { return descriptor; } const schemaJson = await store.readResource(config.sourceDir, descriptor); - const componentNames = getArtifactSchemaComponentNames(schemaJson); - if (!componentNames || componentNames.length === 0) { + const artifactComponents = getArtifactSchemaComponents(schemaJson); + if (!artifactComponents) { return descriptor; } - const recreatedByImport = componentNames.every((name) => - importSpecSchemaNames.has(name) + const entries = Object.entries(artifactComponents); + if (entries.length === 0) { + return descriptor; + } + const recreatedByImport = entries.every( + ([name, definition]) => + Object.hasOwn(specComponents, name) && + deepEqualUnordered(specComponents[name], definition) ); return recreatedByImport ? undefined : descriptor; }) @@ -1083,16 +1091,16 @@ function detectSpecDialect(content: string, format: string | undefined): ApiSpec } /** - * Extracts the set of schema component names declared in an OpenAPI/Swagger + * Extracts the schema component definitions declared in an OpenAPI/Swagger * spec document: `components.schemas` (OpenAPI 3.x) or `definitions` * (Swagger 2.0). Returns undefined when the content is not a parseable * OpenAPI/Swagger document (e.g. WSDL/GraphQL) — callers must treat that as * "no evidence" rather than "no schemas". */ -function getSpecComponentSchemaNames( +function getSpecComponentSchemas( content: string, format: string | undefined -): Set | undefined { +): Record | undefined { if (format !== undefined && format !== 'yaml' && format !== 'json') { return undefined; } @@ -1102,32 +1110,66 @@ function getSpecComponentSchemaNames( const components = (doc.components as Record | undefined)?.schemas ?? doc.definitions; if (components && typeof components === 'object' && !Array.isArray(components)) { - return new Set(Object.keys(components)); + return components as Record; } - return new Set(); + return {}; } catch { return undefined; } } /** - * Extracts the schema component names defined by an ApiSchema artifact's + * Extracts the schema component definitions from an ApiSchema artifact's * `properties.document` (`components.schemas` or Swagger `definitions`). + * Only OpenAPI/Swagger content types are inspected: in a standalone JSON + * Schema document (`schemaType: json`) `definitions` has a different meaning + * and the spec import does not recreate such a resource, so returns + * undefined ("no evidence") for any other content type. */ -function getArtifactSchemaComponentNames( +function getArtifactSchemaComponents( json: Record | null | undefined -): string[] | undefined { +): Record | undefined { const props = json?.properties as Record | undefined; const doc = props?.document as Record | undefined; if (!doc || typeof doc !== 'object') return undefined; + const contentType = (props?.contentType as string | undefined)?.toLowerCase() ?? ''; + if ( + !contentType.includes('openapi.components') && + !contentType.includes('swagger.definitions') + ) { + return undefined; + } const components = (doc.components as Record | undefined)?.schemas ?? doc.definitions; if (components && typeof components === 'object' && !Array.isArray(components)) { - return Object.keys(components); + return components as Record; } return undefined; } +/** + * Deep structural equality with order-insensitive object keys (array order + * still matters — e.g. `required` lists are order-preserving in serialized + * specs but semantically it is safer to demand exact array equality). + */ +function deepEqualUnordered(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, i) => deepEqualUnordered(item, b[i])); + } + if (a && b && typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + if (aKeys.length !== Object.keys(bObj).length) return false; + return aKeys.every( + (key) => Object.hasOwn(bObj, key) && deepEqualUnordered(aObj[key], bObj[key]) + ); + } + return false; +} + /** * Sanitize an OpenAPI spec before importing into APIM. * Currently handles: diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index d440cb97..ca328eeb 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -1982,6 +1982,104 @@ describe('api-publisher', () => { expect(totalTasks).toBe(1); }); + it('should re-publish a 13-digit-named schema when a same-named component has a different shape', async () => { + const client = createMockClient(); + // Same component name, different definition: the spec does NOT recreate + // this schema's data, so it must be retained. + const timestampSchema = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '1786466527403'], + }; + const store = createMockStore([timestampSchema]); + 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.oai.openapi.components+json', + document: { + components: { + schemas: { + Item: { + type: 'object', + required: ['id'], + properties: { id: { type: 'integer' } }, + }, + }, + }, + }, + }, + }; + } + return null; + }); + store.readContent.mockResolvedValue({ + content: JSON.stringify({ + openapi: '3.0.1', + info: { title: 'rest-api', version: '1.0' }, + paths: {}, + components: { schemas: { Item: { type: 'object' } } }, + }), + format: 'json', + }); + + 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 re-publish a 13-digit-named standalone JSON Schema even when definition names overlap the spec', async () => { + const client = createMockClient(); + // schemaType json: `definitions` are JSON Schema definitions, not Swagger + // components — spec import does not recreate this resource. + const timestampSchema = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '1786466527403'], + }; + const store = createMockStore([timestampSchema]); + 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.schema.json', + document: { definitions: { Item: { type: 'object' } } }, + }, + }; + } + return null; + }); + store.readContent.mockResolvedValue({ + content: JSON.stringify({ + openapi: '3.0.1', + info: { title: 'rest-api', version: '1.0' }, + paths: {}, + components: { schemas: { Item: { type: 'object' } } }, + }), + format: 'json', + }); + + 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 re-publish a 13-digit-named schema when the imported spec declares no components', async () => { const client = createMockClient(); const timestampSchema = { From 85df1750c31daa8496ea3eaf2bb724281f67115c Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 11 Sep 2026 20:30:37 +0000 Subject: [PATCH 6/6] fix: tolerate unreadable schema artifacts when evaluating 13-digit skip Address PR review: readResource can throw for malformed or unreadable schemaInformation.json, and the call runs inside Promise.all -- a single bad 13-digit schema aborted planApiPublication before the root API was published, contradicting the 'any ambiguity -> publish' fallback. Catch the read per descriptor and return the descriptor so the normal child publication path reports the failure. Test: root API publishes and the schema is retained when its artifact read throws. --- src/services/api-publisher.ts | 7 ++++- tests/unit/services/api-publisher.test.ts | 38 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/services/api-publisher.ts b/src/services/api-publisher.ts index bdd47429..265ecb36 100644 --- a/src/services/api-publisher.ts +++ b/src/services/api-publisher.ts @@ -342,7 +342,12 @@ export async function planApiPublication( if (!specComponents || Object.keys(specComponents).length === 0) { return descriptor; } - const schemaJson = await store.readResource(config.sourceDir, descriptor); + let schemaJson: Record | undefined; + try { + schemaJson = await store.readResource(config.sourceDir, descriptor); + } catch { + return descriptor; + } const artifactComponents = getArtifactSchemaComponents(schemaJson); if (!artifactComponents) { return descriptor; diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index ca328eeb..ced83623 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -1982,6 +1982,44 @@ describe('api-publisher', () => { expect(totalTasks).toBe(1); }); + it('should publish the root API and retain a 13-digit schema when its artifact cannot be read', async () => { + const client = createMockClient(); + const timestampSchema: ResourceDescriptor = { + type: ResourceType.ApiSchema, + nameParts: ['rest-api', '1786466527403'], + }; + const store = createMockStore([timestampSchema]); + 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) { + throw new Error('Malformed schemaInformation.json'); + } + return null; + }); + store.readContent.mockResolvedValue({ + content: JSON.stringify({ + openapi: '3.0.1', + info: { title: 'rest-api', version: '1.0' }, + paths: {}, + components: { schemas: { Item: { type: 'object' } } }, + }), + format: 'json', + }); + + const apiDescriptor: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['rest-api'] }; + const result = await publishApi(client, store, testContext, apiDescriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalled(); + 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 re-publish a 13-digit-named schema when a same-named component has a different shape', async () => { const client = createMockClient(); // Same component name, different definition: the spec does NOT recreate