Skip to content

Commit 22a966d

Browse files
author
Alexander Zaslonov
committed
chore: resolve PR #272 conflicts with main
2 parents e061ba4 + 5e1dd93 commit 22a966d

8 files changed

Lines changed: 613 additions & 4 deletions

File tree

‎src/clients/apim-client.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,8 @@ export class ApimClient implements IApimClient {
774774

775775
async validatePreFlight(context: ApimServiceContext): Promise<void> {
776776
// Check resource group exists
777-
const rgUrl = `https://management.azure.com/subscriptions/${encodeURIComponent(context.subscriptionId)}/resourceGroups/${encodeURIComponent(context.resourceGroup)}?api-version=${ApimClient.RESOURCE_GROUP_API_VERSION}`;
777+
const armEndpoint = new URL(context.baseUrl).origin;
778+
const rgUrl = `${armEndpoint}/subscriptions/${encodeURIComponent(context.subscriptionId)}/resourceGroups/${encodeURIComponent(context.resourceGroup)}?api-version=${ApimClient.RESOURCE_GROUP_API_VERSION}`;
778779
let rgResponse: Response;
779780
try {
780781
rgResponse = await this.request(rgUrl, { method: 'GET' });

‎src/lib/auto-generated.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
*/
1818
const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/;
1919

20+
/**
21+
* Pattern matching epoch-milliseconds schema IDs (Date.now()-style). The Azure
22+
* portal generates these client-side when a definition is added to an API that
23+
* has no schema resource yet (operation Frontend editor → "New definition").
24+
* Verified: ARM spec import generates 24-hex IDs on every api-version
25+
* 2021-08-01 … 2025-09-01-preview, so 13-digit IDs never come from import.
26+
* Example: "1786466527403"
27+
*/
28+
const PORTAL_SCHEMA_ID_PATTERN = /^\d{13}$/;
29+
2030
/**
2131
* Checks if a resource name/ID is an auto-generated 24-character hex ID.
2232
*
@@ -35,3 +45,21 @@ const AUTO_GENERATED_ID_PATTERN = /^[0-9a-f]{24}$/;
3545
export function isAutoGeneratedId(name: string): boolean {
3646
return AUTO_GENERATED_ID_PATTERN.test(name);
3747
}
48+
49+
/**
50+
* Checks if a schema name is a portal-generated 13-digit epoch-millis ID.
51+
*
52+
* Deliberately separate from isAutoGeneratedId: a 13-digit name is a weaker
53+
* heuristic than 24-hex, so it must only be applied where a false positive is
54+
* harmless — skipping ApiSchema re-publish when a spec import recreates the
55+
* schema content anyway. It must NOT gate named values, subscriptions, or
56+
* operations, where a silently skipped resource would lose data.
57+
*
58+
* @example
59+
* isPortalGeneratedSchemaId('1786466527403') // true
60+
* isPortalGeneratedSchemaId('178646652740') // false (12 digits)
61+
* isPortalGeneratedSchemaId('my-schema') // false
62+
*/
63+
export function isPortalGeneratedSchemaId(name: string): boolean {
64+
return PORTAL_SCHEMA_ID_PATTERN.test(name);
65+
}

‎src/services/api-publisher.ts‎

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
getPublishTier,
3232
getResourceDescriptorKey,
3333
} from '../lib/resource-path.js';
34-
import { isAutoGeneratedId } from '../lib/auto-generated.js';
34+
import { isAutoGeneratedId, isPortalGeneratedSchemaId } from '../lib/auto-generated.js';
3535
import { resolveWorkspaceFilter, shouldIncludeResource } from './filter-service.js';
3636

3737
/**
@@ -277,6 +277,7 @@ export async function planApiPublication(
277277

278278
let importSpecification = false;
279279
let importSpecificationFormat: string | undefined;
280+
let importSpecSchemaComponents: Record<string, unknown> | undefined;
280281
let operationDescriptionPuts: ResourceDescriptor[] = [];
281282
if (specificationAllowed) {
282283
const specification = await store.readContent(config.sourceDir, apiDescriptor, 'specification');
@@ -288,6 +289,10 @@ export async function planApiPublication(
288289
getImportFormat(specification.format ?? 'yaml', apiType, dialect) !== undefined;
289290
importSpecificationFormat = importSpecification ? specification.format : undefined;
290291
if (importSpecification) {
292+
importSpecSchemaComponents = getSpecComponentSchemas(
293+
specification.content,
294+
specification.format
295+
);
291296
operationDescriptionPuts = getOpenApiOperationIdsWithNullDescription(
292297
specification.content,
293298
specification.format
@@ -316,13 +321,52 @@ export async function planApiPublication(
316321
}
317322

318323
if (importSpecification) {
319-
const explicitSchemas = allDescriptors.filter(
324+
const candidateSchemas = allDescriptors.filter(
320325
(descriptor) =>
321326
descriptor.type === ResourceType.ApiSchema &&
322327
getNamePart(descriptor.nameParts, 0).toLowerCase() === apiName.toLowerCase() &&
323328
descriptor.workspace === apiDescriptor.workspace &&
324329
!isAutoGeneratedId(getNamePart(descriptor.nameParts, 1))
325330
);
331+
// Portal-created schemas (13-digit Date.now() names) duplicate on re-PUT
332+
// because the spec import recreates their content (#274). A 13-digit name
333+
// alone is a weak signal, so only skip when the imported spec provably
334+
// recreates every component the artifact schema defines — same name AND
335+
// structurally identical definition. Any ambiguity → publish the schema
336+
// like any explicitly named one.
337+
const explicitSchemas = (
338+
await Promise.all(
339+
candidateSchemas.map(async (descriptor) => {
340+
if (!isPortalGeneratedSchemaId(getNamePart(descriptor.nameParts, 1))) {
341+
return descriptor;
342+
}
343+
const specComponents = importSpecSchemaComponents;
344+
if (!specComponents || Object.keys(specComponents).length === 0) {
345+
return descriptor;
346+
}
347+
let schemaJson: Record<string, unknown> | undefined;
348+
try {
349+
schemaJson = await store.readResource(config.sourceDir, descriptor);
350+
} catch {
351+
return descriptor;
352+
}
353+
const artifactComponents = getArtifactSchemaComponents(schemaJson);
354+
if (!artifactComponents) {
355+
return descriptor;
356+
}
357+
const entries = Object.entries(artifactComponents);
358+
if (entries.length === 0) {
359+
return descriptor;
360+
}
361+
const recreatedByImport = entries.every(
362+
([name, definition]) =>
363+
Object.hasOwn(specComponents, name) &&
364+
deepEqualUnordered(specComponents[name], definition)
365+
);
366+
return recreatedByImport ? undefined : descriptor;
367+
})
368+
)
369+
).filter((descriptor): descriptor is ResourceDescriptor => descriptor !== undefined);
326370
// WSDL-import-generated XSD schemas have GUID names (not 24-hex), so the
327371
// isAutoGeneratedId filter misses them. APIM recreates them on WSDL import
328372
// and rejects re-PUTs, so exclude schemas with the XSD content type — but
@@ -1069,6 +1113,86 @@ function detectSpecDialect(content: string, format: string | undefined): ApiSpec
10691113
}
10701114
}
10711115

1116+
/**
1117+
* Extracts the schema component definitions declared in an OpenAPI/Swagger
1118+
* spec document: `components.schemas` (OpenAPI 3.x) or `definitions`
1119+
* (Swagger 2.0). Returns undefined when the content is not a parseable
1120+
* OpenAPI/Swagger document (e.g. WSDL/GraphQL) — callers must treat that as
1121+
* "no evidence" rather than "no schemas".
1122+
*/
1123+
function getSpecComponentSchemas(
1124+
content: string,
1125+
format: string | undefined
1126+
): Record<string, unknown> | undefined {
1127+
if (format !== undefined && format !== 'yaml' && format !== 'json') {
1128+
return undefined;
1129+
}
1130+
try {
1131+
const doc = yaml.load(content) as Record<string, unknown> | undefined;
1132+
if (!doc || typeof doc !== 'object') return undefined;
1133+
const components = (doc.components as Record<string, unknown> | undefined)?.schemas
1134+
?? doc.definitions;
1135+
if (components && typeof components === 'object' && !Array.isArray(components)) {
1136+
return components as Record<string, unknown>;
1137+
}
1138+
return {};
1139+
} catch {
1140+
return undefined;
1141+
}
1142+
}
1143+
1144+
/**
1145+
* Extracts the schema component definitions from an ApiSchema artifact's
1146+
* `properties.document` (`components.schemas` or Swagger `definitions`).
1147+
* Only OpenAPI/Swagger content types are inspected: in a standalone JSON
1148+
* Schema document (`schemaType: json`) `definitions` has a different meaning
1149+
* and the spec import does not recreate such a resource, so returns
1150+
* undefined ("no evidence") for any other content type.
1151+
*/
1152+
function getArtifactSchemaComponents(
1153+
json: Record<string, unknown> | null | undefined
1154+
): Record<string, unknown> | undefined {
1155+
const props = json?.properties as Record<string, unknown> | undefined;
1156+
const doc = props?.document as Record<string, unknown> | undefined;
1157+
if (!doc || typeof doc !== 'object') return undefined;
1158+
const contentType = (props?.contentType as string | undefined)?.toLowerCase() ?? '';
1159+
if (
1160+
!contentType.includes('openapi.components') &&
1161+
!contentType.includes('swagger.definitions')
1162+
) {
1163+
return undefined;
1164+
}
1165+
const components = (doc.components as Record<string, unknown> | undefined)?.schemas
1166+
?? doc.definitions;
1167+
if (components && typeof components === 'object' && !Array.isArray(components)) {
1168+
return components as Record<string, unknown>;
1169+
}
1170+
return undefined;
1171+
}
1172+
1173+
/**
1174+
* Deep structural equality with order-insensitive object keys (array order
1175+
* still matters — e.g. `required` lists are order-preserving in serialized
1176+
* specs but semantically it is safer to demand exact array equality).
1177+
*/
1178+
function deepEqualUnordered(a: unknown, b: unknown): boolean {
1179+
if (a === b) return true;
1180+
if (Array.isArray(a) || Array.isArray(b)) {
1181+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
1182+
return a.every((item, i) => deepEqualUnordered(item, b[i]));
1183+
}
1184+
if (a && b && typeof a === 'object' && typeof b === 'object') {
1185+
const aObj = a as Record<string, unknown>;
1186+
const bObj = b as Record<string, unknown>;
1187+
const aKeys = Object.keys(aObj);
1188+
if (aKeys.length !== Object.keys(bObj).length) return false;
1189+
return aKeys.every(
1190+
(key) => Object.hasOwn(bObj, key) && deepEqualUnordered(aObj[key], bObj[key])
1191+
);
1192+
}
1193+
return false;
1194+
}
1195+
10721196
/**
10731197
* Sanitize an OpenAPI spec before importing into APIM.
10741198
* Currently handles:

‎src/services/resource-publisher.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ export async function publishResource(
429429
descriptor.workspace,
430430
config.envMapping
431431
);
432+
json = normalizeSubscriptionOwnerId(json);
432433
}
433434

434435
// ApiRelease: normalize properties.apiId from source ARM path to target ARM path.
@@ -1158,6 +1159,37 @@ function normalizeSubscriptionScope(
11581159
return json;
11591160
}
11601161

1162+
/**
1163+
* Normalise the `properties.ownerId` field of a Subscription resource.
1164+
*
1165+
* APIM returns ownerId as a full ARM resource path on GET, e.g.:
1166+
* /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ApiManagement/service/{svc}/users/1
1167+
*
1168+
* The PUT endpoint requires a relative URL in the format `/users/{userId}`,
1169+
* so a subscription extracted from one instance would otherwise carry the
1170+
* source instance's service path. Strip the service ARM prefix like
1171+
* normalizeSubscriptionScope does for scope. Unlike scope, envMapping
1172+
* affixes must NOT be applied — a user id is not an affixed resource name.
1173+
* Already-relative values are left untouched.
1174+
*/
1175+
function normalizeSubscriptionOwnerId(
1176+
json: Record<string, unknown>
1177+
): Record<string, unknown> {
1178+
const props = json.properties as Record<string, unknown> | undefined;
1179+
const ownerId = props?.ownerId;
1180+
if (typeof ownerId !== 'string') return json;
1181+
1182+
const serviceOwnerMatch = ownerId.match(
1183+
/^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.ApiManagement\/service\/[^/]+(\/users\/[^/]+)$/i
1184+
);
1185+
if (!serviceOwnerMatch?.[1]) return json;
1186+
1187+
return {
1188+
...json,
1189+
properties: { ...props, ownerId: serviceOwnerMatch[1] },
1190+
};
1191+
}
1192+
11611193
export function applyApiPathPrefix(
11621194
json: Record<string, unknown>,
11631195
descriptor: ResourceDescriptor,

‎tests/unit/clients/apim-client.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
99
import { ApimClient, HttpError } from '../../../src/clients/apim-client.js';
1010
import { ResourceType } from '../../../src/models/resource-types.js';
1111
import { ApimServiceContext } from '../../../src/models/types.js';
12+
import { buildArmBaseUrl } from '../../../src/lib/cloud-config.js';
1213

1314
const testContext: ApimServiceContext = {
1415
subscriptionId: 'sub-1',
@@ -31,6 +32,45 @@ function makeResponse(
3132
});
3233
}
3334

35+
describe('ApimClient.validatePreFlight', () => {
36+
afterEach(() => {
37+
vi.restoreAllMocks();
38+
vi.unstubAllGlobals();
39+
});
40+
41+
it.each([
42+
['public', 'https://management.azure.com'],
43+
['usgov', 'https://management.usgovcloudapi.net'],
44+
['china', 'https://management.chinacloudapi.cn'],
45+
['germany', 'https://management.microsoftazure.de'],
46+
])('should use the %s endpoint for both pre-flight checks', async (cloud, endpoint) => {
47+
const client = new ApimClient();
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
vi.spyOn(client as any, 'getToken').mockResolvedValue('fake-token');
50+
const fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(makeResponse(200, {})));
51+
vi.stubGlobal('fetch', fetchSpy);
52+
const context: ApimServiceContext = {
53+
...testContext,
54+
resourceGroup: 'rg with spaces',
55+
baseUrl: buildArmBaseUrl(cloud, testContext.subscriptionId, 'rg with spaces', testContext.serviceName),
56+
};
57+
58+
await client.validatePreFlight(context);
59+
60+
expect(fetchSpy).toHaveBeenCalledTimes(2);
61+
expect(fetchSpy).toHaveBeenNthCalledWith(
62+
1,
63+
`${endpoint}/subscriptions/sub-1/resourceGroups/rg%20with%20spaces?api-version=2021-04-01`,
64+
expect.objectContaining({ method: 'GET' }),
65+
);
66+
expect(fetchSpy).toHaveBeenNthCalledWith(
67+
2,
68+
`${context.baseUrl}?api-version=${context.apiVersion}`,
69+
expect.objectContaining({ method: 'GET' }),
70+
);
71+
});
72+
});
73+
3474
describe('ApimClient.listResources', () => {
3575
let client: ApimClient;
3676
let fetchSpy: ReturnType<typeof vi.fn>;

‎tests/unit/lib/auto-generated.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33
import { describe, it, expect } from 'vitest';
4-
import { isAutoGeneratedId } from '../../../src/lib/auto-generated.js';
4+
import { isAutoGeneratedId, isPortalGeneratedSchemaId } from '../../../src/lib/auto-generated.js';
55

66
describe('auto-generated', () => {
77
describe('isAutoGeneratedId', () => {
@@ -12,6 +12,13 @@ describe('auto-generated', () => {
1212
expect(isAutoGeneratedId('ffffffffffffffffffffffff')).toBe(true);
1313
});
1414

15+
it('should NOT match 13-digit portal schema IDs (handled by isPortalGeneratedSchemaId)', () => {
16+
// 13-digit is a weaker heuristic; it must not gate named values,
17+
// subscriptions, or operations that share this predicate.
18+
expect(isAutoGeneratedId('1786466527403')).toBe(false);
19+
expect(isAutoGeneratedId('1700000000000')).toBe(false);
20+
});
21+
1522
it('should return false for human-readable schema names', () => {
1623
expect(isAutoGeneratedId('src-rest-schema-item')).toBe(false);
1724
expect(isAutoGeneratedId('my-schema')).toBe(false);
@@ -40,4 +47,24 @@ describe('auto-generated', () => {
4047
expect(isAutoGeneratedId('69f15c3c10a45d29d855583-')).toBe(false);
4148
});
4249
});
50+
51+
describe('isPortalGeneratedSchemaId', () => {
52+
it('should return true for 13-digit epoch-millis IDs (portal-created schemas)', () => {
53+
expect(isPortalGeneratedSchemaId('1786466527403')).toBe(true);
54+
expect(isPortalGeneratedSchemaId('1789146226316')).toBe(true);
55+
});
56+
57+
it('should return false for numeric IDs of other lengths', () => {
58+
expect(isPortalGeneratedSchemaId('178646652740')).toBe(false); // 12 digits
59+
expect(isPortalGeneratedSchemaId('17864665274031')).toBe(false); // 14 digits
60+
expect(isPortalGeneratedSchemaId('1')).toBe(false);
61+
expect(isPortalGeneratedSchemaId('')).toBe(false);
62+
});
63+
64+
it('should return false for 24-hex and human-readable names', () => {
65+
expect(isPortalGeneratedSchemaId('69f15c3c10a45d29d855583a')).toBe(false);
66+
expect(isPortalGeneratedSchemaId('my-schema')).toBe(false);
67+
expect(isPortalGeneratedSchemaId('1786466527403x')).toBe(false);
68+
});
69+
});
4370
});

0 commit comments

Comments
 (0)