Skip to content
28 changes: 28 additions & 0 deletions src/lib/auto-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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);
}
128 changes: 126 additions & 2 deletions src/services/api-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -276,6 +276,7 @@ export async function planApiPublication(
);

let importSpecification = false;
let importSpecSchemaComponents: Record<string, unknown> | undefined;
let operationDescriptionPuts: ResourceDescriptor[] = [];
if (specificationAllowed) {
const specification = await store.readContent(config.sourceDir, apiDescriptor, 'specification');
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown> | 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)
Expand Down Expand Up @@ -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<string, unknown> | undefined {
if (format !== undefined && format !== 'yaml' && format !== 'json') {
return undefined;
}
try {
const doc = yaml.load(content) as Record<string, unknown> | undefined;
if (!doc || typeof doc !== 'object') return undefined;
const components = (doc.components as Record<string, unknown> | undefined)?.schemas
?? doc.definitions;
if (components && typeof components === 'object' && !Array.isArray(components)) {
return components as Record<string, unknown>;
}
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<string, unknown> | null | undefined
): Record<string, unknown> | undefined {
const props = json?.properties as Record<string, unknown> | undefined;
const doc = props?.document as Record<string, unknown> | 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<string, unknown> | undefined)?.schemas
?? doc.definitions;
if (components && typeof components === 'object' && !Array.isArray(components)) {
return components as Record<string, unknown>;
}
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<string, unknown>;
const bObj = b as Record<string, unknown>;
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:
Expand Down
29 changes: 28 additions & 1 deletion tests/unit/lib/auto-generated.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
});
});
Loading