diff --git a/src/lib/auto-generated.ts b/src/lib/auto-generated.ts index 970b2f00..a35b67a2 100644 --- a/src/lib/auto-generated.ts +++ b/src/lib/auto-generated.ts @@ -17,6 +17,16 @@ */ const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/; +/** + * 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 PORTAL_SCHEMA_ID_PATTERN = /^\d{13}$/; + /** * Checks if a resource name/ID is an auto-generated 24-character hex ID. * @@ -35,3 +45,21 @@ const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/; export function isAutoGeneratedId(name: string): boolean { 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..265ecb36 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'; /** @@ -276,6 +276,7 @@ export async function planApiPublication( ); let importSpecification = false; + let importSpecSchemaComponents: Record | 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) { + importSpecSchemaComponents = getSpecComponentSchemas( + specification.content, + specification.format + ); operationDescriptionPuts = getOpenApiOperationIdsWithNullDescription( specification.content, specification.format @@ -314,13 +319,52 @@ 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) 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 + // 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; + } + const specComponents = importSpecSchemaComponents; + if (!specComponents || Object.keys(specComponents).length === 0) { + return descriptor; + } + let schemaJson: Record | undefined; + try { + schemaJson = await store.readResource(config.sourceDir, descriptor); + } catch { + return descriptor; + } + const artifactComponents = getArtifactSchemaComponents(schemaJson); + if (!artifactComponents) { + return descriptor; + } + 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; + }) + ) + ).filter((descriptor): descriptor is ResourceDescriptor => descriptor !== undefined); const filteredExplicitSchemas = config.filter ? explicitSchemas.filter((descriptor) => shouldIncludeResource(descriptor, effectiveFilter) @@ -1051,6 +1095,86 @@ function detectSpecDialect(content: string, format: string | undefined): ApiSpec } } +/** + * 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 getSpecComponentSchemas( + content: string, + format: string | undefined +): Record | 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 components as Record; + } + return {}; + } catch { + return undefined; + } +} + +/** + * 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 getArtifactSchemaComponents( + json: Record | null | 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 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/lib/auto-generated.test.ts b/tests/unit/lib/auto-generated.test.ts index 26e39efb..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,6 +12,13 @@ describe('auto-generated', () => { expect(isAutoGeneratedId('ffffffffffffffffffffffff')).toBe(true); }); + 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', () => { expect(isAutoGeneratedId('src-rest-schema-item')).toBe(false); expect(isAutoGeneratedId('my-schema')).toBe(false); @@ -40,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); + }); + }); }); diff --git a/tests/unit/services/api-publisher.test.ts b/tests/unit/services/api-publisher.test.ts index 761a3296..ced83623 100644 --- a/tests/unit/services/api-publisher.test.ts +++ b/tests/unit/services/api-publisher.test.ts @@ -1894,6 +1894,264 @@ describe('api-publisher', () => { expect(totalTasks).toBe(1); }); + it('should skip 13-digit portal schema when the imported spec recreates its components', async () => { + const client = createMockClient(); + // 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'], + }; + 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' } } } }, + }, + }; + } + 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(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 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 + // 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 = { + 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();