Skip to content

Commit 42f182d

Browse files
authored
Merge pull request #263 from Alexey-Zheltov/main
This PR fixes several bugs in `extract` and `publish` related to API revision handling, resource deletion, and authentication settings, plus a dependency security bump.
2 parents d5dd33f + 956381d commit 42f182d

18 files changed

Lines changed: 1257 additions & 83 deletions

‎package-lock.json‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/clients/apim-client.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,18 @@ export class ApimClient implements IApimClient {
207207

208208
return response;
209209
} catch (error) {
210-
// Do not retry client errors (4xx) — they are deterministic, not transient.
211-
// 429 rate-limiting is already handled above and never reaches here.
212-
if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
210+
// APIM reports operations blocked by an API's in-progress async operation
211+
// as a transient 409. Other client errors are deterministic.
212+
const isPessimisticConcurrencyConflict =
213+
error instanceof HttpError &&
214+
error.status === 409 &&
215+
error.code === 'PessimisticConcurrencyConflict';
216+
if (
217+
error instanceof HttpError &&
218+
error.status >= 400 &&
219+
error.status < 500 &&
220+
!isPessimisticConcurrencyConflict
221+
) {
213222
throw error;
214223
}
215224
if (attempt >= ApimClient.MAX_RETRIES) {
@@ -454,7 +463,18 @@ export class ApimClient implements IApimClient {
454463
context: ApimServiceContext,
455464
descriptor: ResourceDescriptor
456465
): Promise<boolean> {
457-
const url = buildArmUri(context, descriptor);
466+
let url = buildArmUri(context, descriptor);
467+
468+
// Deleting an API that has revisions requires deleteRevisions=true; without it
469+
// APIM refuses the base (current-revision) delete with "Cannot delete the
470+
// current revision of an API." This also removes all revisions in one call,
471+
// so callers skip the individual ;rev=N deletes.
472+
if (
473+
descriptor.type === ResourceType.Api &&
474+
!(descriptor.nameParts[0] ?? '').includes(';rev=')
475+
) {
476+
url += '&deleteRevisions=true';
477+
}
458478

459479
for (let attempt = 1; ; attempt++) {
460480
try {
@@ -482,6 +502,15 @@ export class ApimClient implements IApimClient {
482502
if (message.includes('404')) {
483503
return false;
484504
}
505+
// Resource is still referenced by another entity (e.g. a policy fragment
506+
// used by the service policy). It cannot be deleted until the reference is
507+
// removed; skip it with a warning instead of failing the whole prune.
508+
if (message.includes('is used by the following entities')) {
509+
logger.warn(
510+
`Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity`
511+
);
512+
return false;
513+
}
485514
// Transient optimistic-concurrency conflict: cascade deletes of related
486515
// resources (subscriptions, product/gateway associations) can modify
487516
// the resource while its async DELETE is in flight. Retry the DELETE.

‎src/lib/resource-path.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,16 @@ export function getNamePart(nameParts: string[], index: number): string {
143143
return value;
144144
}
145145

146+
/** True when an API name carries a revision suffix (e.g. "my-api;rev=2"). */
147+
export function isApiRevisionName(apiName: string): boolean {
148+
return apiName.includes(';rev=');
149+
}
150+
151+
/** Root API name with any ";rev=N" suffix stripped. */
152+
export function getApiRootName(apiName: string): string {
153+
return apiName.split(';rev=')[0] ?? apiName;
154+
}
155+
146156
/**
147157
* Converts a positional template string to a capturing regex.
148158
* Each `{i}` placeholder becomes a `([^/]+)` capture group; all other

‎src/services/api-extractor.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,10 @@ async function extractApiRevisions(
200200
for await (const revision of revisions) {
201201
try {
202202
const revNumber = (revision.apiRevision ?? revision.revisionNumber) as string | undefined;
203-
if (!revNumber || revNumber === '1') {
204-
// Skip revision 1 — it's the main API
203+
// Skip the current revision — it is represented by the main API folder.
204+
// Using isCurrent (not a hard-coded '1') correctly handles APIs whose
205+
// current revision is not revision 1.
206+
if (!revNumber || revision.isCurrent === true) {
205207
continue;
206208
}
207209

‎src/services/api-publisher.ts‎

Lines changed: 121 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ import type { PublishConfig } from '../models/config.js';
1414
import * as yaml from 'js-yaml';
1515
import { ResourceType } from '../models/resource-types.js';
1616
import {
17+
normalizeApiAuthenticationSettings,
1718
normalizeMcpToolOperationIds,
19+
prefersLegacyAuthOverride,
1820
publishResource,
1921
type ResourcePublishResult,
2022
} from './resource-publisher.js';
2123
import { runParallel } from '../lib/parallel-runner.js';
2224
import { applyOverrides } from './override-merger.js';
25+
import { mapDescriptor } from './env-mapper.js';
2326
import { logger } from '../lib/logger.js';
2427
import { getNamePart, getPublishTier } from '../lib/resource-path.js';
2528
import { isAutoGeneratedId } from '../lib/auto-generated.js';
@@ -54,8 +57,26 @@ export async function publishApi(
5457
config: PublishConfig
5558
): Promise<ResourcePublishResult> {
5659
try {
57-
// Step 1: Publish root API (with spec import if available)
58-
const rootResult = await publishRootApi(client, store, context, descriptor, config);
60+
// Deployed (env-mapped) base descriptor — derived once and used for every
61+
// direct APIM call so canonical and affixed names can never diverge.
62+
const deployedDescriptor = config.envMapping
63+
? mapDescriptor(descriptor, config.envMapping)
64+
: descriptor;
65+
66+
// Step 1: Publish root API (with spec import if available).
67+
// On a fresh target the root is created at its source revision number so
68+
// it cannot collide with ;rev=N revision artifacts.
69+
const putDescriptor = await resolveRootApiPutDescriptor(
70+
client,
71+
store,
72+
context,
73+
descriptor,
74+
deployedDescriptor,
75+
config
76+
);
77+
const rootResult = await publishRootApi(client, store, context, descriptor, config, {
78+
putDescriptor,
79+
});
5980
if (rootResult.status !== 'success') {
6081
return rootResult;
6182
}
@@ -64,7 +85,7 @@ export async function publishApi(
6485
await alignImportedOperationDescriptions(
6586
client,
6687
context,
67-
descriptor,
88+
deployedDescriptor,
6889
rootResult.operationIdsWithNullDescription
6990
);
7091
}
@@ -75,13 +96,10 @@ export async function publishApi(
7596
// Step 2b: Align root API only when source marks it as current.
7697
// Source of truth is properties.isCurrent in root apiInformation.json.
7798
if (publishedRevisionCount > 0 && rootResult.isCurrent === true) {
78-
const alignResult = await alignActiveRevisionWithSource(
79-
client,
80-
store,
81-
context,
82-
descriptor,
83-
config
84-
);
99+
const alignResult = await publishRootApi(client, store, context, descriptor, config, {
100+
includeSpecification: false,
101+
putDescriptor: deployedDescriptor,
102+
});
85103
if (alignResult.status !== 'success') {
86104
return alignResult;
87105
}
@@ -148,6 +166,49 @@ interface RootApiResult {
148166

149167
interface PublishRootApiOptions {
150168
includeSpecification?: boolean;
169+
/** Descriptor to PUT to (defaults to the artifact descriptor). Lets the root
170+
* API be created at apis/{name};rev=N while still reading apis/{name} artifacts. */
171+
putDescriptor?: ResourceDescriptor;
172+
}
173+
174+
/**
175+
* A plain root PUT creates a brand-new API as revision 1, which collides with
176+
* a ;rev=1 revision artifact and silently absorbs it whenever the source's
177+
* current revision number is > 1. When the API does not yet exist on the
178+
* target, PUT the root at its true revision number (apis/{name};rev=N) instead.
179+
* Existing APIs keep the plain root PUT — their current revision cannot be
180+
* renumbered.
181+
*
182+
* Reads artifacts via the canonical descriptor; all APIM lookups and the
183+
* returned PUT target use the deployed (env-mapped) descriptor, with ;rev=N
184+
* appended after affixing so suffix mappings cannot corrupt it.
185+
*/
186+
async function resolveRootApiPutDescriptor(
187+
client: IApimClient,
188+
store: IArtifactStore,
189+
context: ApimServiceContext,
190+
descriptor: ResourceDescriptor,
191+
deployedDescriptor: ResourceDescriptor,
192+
config: PublishConfig
193+
): Promise<ResourceDescriptor> {
194+
const json = await store.readResource(config.sourceDir, descriptor);
195+
const rev = (json?.properties as Record<string, unknown> | undefined)?.apiRevision;
196+
if (typeof rev !== 'string' || rev === '' || rev === '1') {
197+
return deployedDescriptor;
198+
}
199+
200+
const existing = await client.getResource(context, deployedDescriptor);
201+
if (existing) {
202+
return deployedDescriptor;
203+
}
204+
205+
return {
206+
...deployedDescriptor,
207+
nameParts: [
208+
`${getNamePart(deployedDescriptor.nameParts, 0)};rev=${rev}`,
209+
...deployedDescriptor.nameParts.slice(1),
210+
],
211+
};
151212
}
152213

153214
/**
@@ -176,11 +237,18 @@ async function publishRootApi(
176237

177238
// Root APIs publish through api-publisher rather than publishResource, so
178239
// they need the same pre-override MCP tool normalization here that revision
179-
// APIs receive in resource-publisher.
180-
json = normalizeMcpToolOperationIds(json, context);
240+
// APIs receive in resource-publisher — including env-mapped API names so the
241+
// tool operationIds match the affixed API this PUT targets.
242+
json = normalizeMcpToolOperationIds(json, context, config.envMapping);
181243

182244
// Apply overrides
183245
json = applyOverrides(descriptor, json, config.overrides);
246+
json = normalizeApiAuthenticationSettings(json, {
247+
preferLegacyFields: prefersLegacyAuthOverride(
248+
getNamePart(descriptor.nameParts, 0),
249+
config.overrides?.apis
250+
),
251+
});
184252
const isCurrent = getApiIsCurrent(json);
185253

186254
// Try to read the specification file for this API
@@ -239,7 +307,7 @@ async function publishRootApi(
239307
}
240308

241309
// PUT the API resource to APIM
242-
await client.putResource(context, descriptor, json);
310+
await client.putResource(context, options?.putDescriptor ?? descriptor, json);
243311

244312
return {
245313
descriptor,
@@ -251,22 +319,6 @@ async function publishRootApi(
251319
};
252320
}
253321

254-
async function alignActiveRevisionWithSource(
255-
client: IApimClient,
256-
store: IArtifactStore,
257-
context: ApimServiceContext,
258-
descriptor: ResourceDescriptor,
259-
config: PublishConfig
260-
): Promise<RootApiResult & ResourcePublishResult> {
261-
logger.debug(
262-
`Source marks "${getNamePart(descriptor.nameParts, 0)}" as current; re-applying root metadata to align active revision`
263-
);
264-
265-
return publishRootApi(client, store, context, descriptor, config, {
266-
includeSpecification: false,
267-
});
268-
}
269-
270322
/**
271323
* Find and publish API revisions in numeric order
272324
*/
@@ -294,12 +346,41 @@ async function publishApiRevisions(
294346
return revA - revB;
295347
});
296348

297-
// Publish each revision in order
349+
// The current revision is already published as the root API, so skip a
350+
// revision artifact that carries the same number — otherwise we re-create /
351+
// collide with it (e.g. the root PUT created at ;rev=N for a fresh
352+
// multi-revision API, or a stale ;rev=N folder from an older extract).
353+
const rootJson = await store.readResource(config.sourceDir, apiDescriptor);
354+
const rootRevision = (rootJson?.properties as Record<string, unknown> | undefined)?.apiRevision;
355+
const rootRevisionNumber =
356+
typeof rootRevision === 'string' && rootRevision !== '' ? Number(rootRevision) : undefined;
357+
358+
// Publish each revision in order; a failed revision must fail the API —
359+
// otherwise errors are silently swallowed and the exit code stays 0.
360+
let publishedCount = 0;
298361
for (const revDescriptor of sortedRevisions) {
299-
await publishResource(client, store, context, revDescriptor, config);
362+
if (
363+
rootRevisionNumber !== undefined &&
364+
extractRevisionNumber(getNamePart(revDescriptor.nameParts, 0)) === rootRevisionNumber
365+
) {
366+
logger.debug(
367+
`Skipping revision ${getNamePart(revDescriptor.nameParts, 0)} — already published as the root API`
368+
);
369+
continue;
370+
}
371+
const result = await publishResource(client, store, context, revDescriptor, config);
372+
if (result.status === 'failed') {
373+
throw new Error(
374+
`Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ` +
375+
`${result.error?.message ?? 'unknown error'}`
376+
);
377+
}
378+
if (result.status === 'success') publishedCount++;
300379
}
301380

302-
return sortedRevisions.length;
381+
// Return only revisions actually published so the caller does not run a
382+
// spurious active-revision alignment PUT when nothing changed.
383+
return publishedCount;
303384
}
304385

305386
/**
@@ -467,8 +548,15 @@ async function reconcileOperationsAfterSpecImport(
467548

468549
const patchBody: Record<string, unknown> = { properties: patchProps };
469550

551+
// Artifact lookup above uses the canonical descriptor; the PATCH must target
552+
// the deployed (env-mapped) name so reconciliation hits the API that the
553+
// root create actually produced under environment mapping.
554+
const patchDescriptor = config.envMapping
555+
? mapDescriptor(descriptor, config.envMapping)
556+
: descriptor;
557+
470558
try {
471-
await client.patchResource(context, descriptor, patchBody);
559+
await client.patchResource(context, patchDescriptor, patchBody);
472560
logger.debug(`Reconciled operation "${getNamePart(descriptor.nameParts, 1)}" after spec import`);
473561
} catch (error) {
474562
logger.warn(

‎src/services/delete-unmatched-service.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,54 @@ import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'
2929
import type { PublishConfig } from '../models/config.js';
3030
import { ResourceType } from '../models/resource-types.js';
3131
import { getTopologicalOrder } from '../lib/dependency-graph.js';
32-
import { getNameFromNameParts } from '../lib/resource-path.js';
32+
import {
33+
getNameFromNameParts,
34+
getNamePart,
35+
getApiRootName,
36+
isApiRevisionName,
37+
} from '../lib/resource-path.js';
3338
import { logger } from '../lib/logger.js';
3439
import { toCanonicalDescriptor } from './env-mapper.js';
3540

41+
/**
42+
* Drop ;rev=N API deletes whose base API (same workspace) is also queued for
43+
* deletion. The base API delete uses deleteRevisions=true and removes all
44+
* revisions in one call, so individual revision deletes are redundant and can
45+
* hit APIM's "Cannot delete the current revision of an API" error.
46+
*
47+
* Shared by the real delete path and the dry-run reporter so the preview
48+
* matches what publish would actually delete.
49+
*/
50+
export function filterRevisionDeletesHandledByBaseApi(
51+
descriptors: ResourceDescriptor[]
52+
): ResourceDescriptor[] {
53+
const baseApiKeys = new Set<string>();
54+
for (const descriptor of descriptors) {
55+
if (descriptor.type !== ResourceType.Api) {
56+
continue;
57+
}
58+
const apiName = getNamePart(descriptor.nameParts, 0);
59+
if (!isApiRevisionName(apiName)) {
60+
baseApiKeys.add(`${descriptor.workspace ?? ''}::${apiName}`);
61+
}
62+
}
63+
64+
if (baseApiKeys.size === 0) {
65+
return descriptors;
66+
}
67+
68+
return descriptors.filter((descriptor) => {
69+
if (descriptor.type !== ResourceType.Api) {
70+
return true;
71+
}
72+
const apiName = getNamePart(descriptor.nameParts, 0);
73+
if (!isApiRevisionName(apiName)) {
74+
return true;
75+
}
76+
return !baseApiKeys.has(`${descriptor.workspace ?? ''}::${getApiRootName(apiName)}`);
77+
});
78+
}
79+
3680
/**
3781
* Built-in groups that should never be deleted
3882
*/

0 commit comments

Comments
 (0)