Skip to content

Commit 6f5d3b1

Browse files
author
Alexander Zaslonov
committed
chore: merge main into feat/publish-filtering
2 parents 9f38143 + 42f182d commit 6f5d3b1

18 files changed

Lines changed: 1217 additions & 52 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
* Create a case-insensitive identity key for a resource descriptor.
148158
*/

‎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: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import * as yaml from 'js-yaml';
1515
import { ResourceType } from '../models/resource-types.js';
1616
import {
1717
applyApiPathPrefix,
18+
normalizeApiAuthenticationSettings,
1819
normalizeMcpToolOperationIds,
1920
normalizeApiVersionSetId,
21+
prefersLegacyAuthOverride,
2022
publishResource,
2123
type ResourcePublishResult,
2224
} from './resource-publisher.js';
@@ -77,6 +79,7 @@ export async function publishApi(
7779
allowedDescriptors?: ResourceDescriptor[]
7880
): Promise<ResourcePublishResult> {
7981
let publicationPlan: ApiPublicationPlan;
82+
let rootPutDescriptor: ResourceDescriptor;
8083
let rootPublished = false;
8184
try {
8285
publicationPlan = await planApiPublication(
@@ -85,6 +88,20 @@ export async function publishApi(
8588
config,
8689
allowedDescriptors
8790
);
91+
92+
// Deployed (env-mapped) base descriptor — derived once and used for every
93+
// direct APIM call so canonical and affixed names can never diverge.
94+
const deployedDescriptor = config.envMapping
95+
? mapDescriptor(descriptor, config.envMapping)
96+
: descriptor;
97+
rootPutDescriptor = await resolveRootApiPutDescriptor(
98+
client,
99+
store,
100+
context,
101+
descriptor,
102+
deployedDescriptor,
103+
config
104+
);
88105
} catch (error) {
89106
return {
90107
descriptor,
@@ -98,6 +115,7 @@ export async function publishApi(
98115
// Step 1: Publish root API (with spec import if available)
99116
const rootResult = await publishRootApi(client, store, context, descriptor, config, {
100117
includeSpecification: publicationPlan.importSpecification,
118+
putDescriptor: rootPutDescriptor,
101119
});
102120
if (rootResult.status !== 'success') {
103121
return rootResult;
@@ -213,7 +231,7 @@ export async function planApiPublication(
213231
? new Set(allowedDescriptors.map(getResourceDescriptorKey))
214232
: undefined;
215233
const rootName = apiName.toLowerCase();
216-
const revisions = allDescriptors
234+
let revisions = allDescriptors
217235
.filter(
218236
(descriptor) =>
219237
descriptor.type === ResourceType.Api &&
@@ -234,6 +252,14 @@ export async function planApiPublication(
234252
const mergedApiJson = apiJson
235253
? applyOverrides(apiDescriptor, apiJson, config.overrides)
236254
: undefined;
255+
const rootRevision = (apiJson?.properties as Record<string, unknown> | undefined)?.apiRevision;
256+
if (typeof rootRevision === 'string' && rootRevision !== '') {
257+
const rootRevisionNumber = Number(rootRevision);
258+
revisions = revisions.filter(
259+
(revision) =>
260+
extractRevisionNumber(getNamePart(revision.nameParts, 0)) !== rootRevisionNumber
261+
);
262+
}
237263
const specificationAllowed =
238264
subFilter?.operations === undefined &&
239265
subFilter?.schemas === undefined &&
@@ -387,6 +413,49 @@ interface RootApiResult {
387413

388414
interface PublishRootApiOptions {
389415
includeSpecification?: boolean;
416+
/** Descriptor to PUT to (defaults to the artifact descriptor). Lets the root
417+
* API be created at apis/{name};rev=N while still reading apis/{name} artifacts. */
418+
putDescriptor?: ResourceDescriptor;
419+
}
420+
421+
/**
422+
* A plain root PUT creates a brand-new API as revision 1, which collides with
423+
* a ;rev=1 revision artifact and silently absorbs it whenever the source's
424+
* current revision number is > 1. When the API does not yet exist on the
425+
* target, PUT the root at its true revision number (apis/{name};rev=N) instead.
426+
* Existing APIs keep the plain root PUT — their current revision cannot be
427+
* renumbered.
428+
*
429+
* Reads artifacts via the canonical descriptor; all APIM lookups and the
430+
* returned PUT target use the deployed (env-mapped) descriptor, with ;rev=N
431+
* appended after affixing so suffix mappings cannot corrupt it.
432+
*/
433+
async function resolveRootApiPutDescriptor(
434+
client: IApimClient,
435+
store: IArtifactStore,
436+
context: ApimServiceContext,
437+
descriptor: ResourceDescriptor,
438+
deployedDescriptor: ResourceDescriptor,
439+
config: PublishConfig
440+
): Promise<ResourceDescriptor> {
441+
const json = await store.readResource(config.sourceDir, descriptor);
442+
const rev = (json?.properties as Record<string, unknown> | undefined)?.apiRevision;
443+
if (typeof rev !== 'string' || rev === '' || rev === '1') {
444+
return deployedDescriptor;
445+
}
446+
447+
const existing = await client.getResource(context, deployedDescriptor);
448+
if (existing) {
449+
return deployedDescriptor;
450+
}
451+
452+
return {
453+
...deployedDescriptor,
454+
nameParts: [
455+
`${getNamePart(deployedDescriptor.nameParts, 0)};rev=${rev}`,
456+
...deployedDescriptor.nameParts.slice(1),
457+
],
458+
};
390459
}
391460

392461
/**
@@ -420,6 +489,12 @@ async function publishRootApi(
420489

421490
// Apply overrides
422491
json = applyOverrides(descriptor, json, config.overrides);
492+
json = normalizeApiAuthenticationSettings(json, {
493+
preferLegacyFields: prefersLegacyAuthOverride(
494+
getNamePart(descriptor.nameParts, 0),
495+
config.overrides?.apis
496+
),
497+
});
423498
const isCurrent = getApiIsCurrent(json);
424499

425500
// Try to read the specification file for this API
@@ -482,7 +557,7 @@ async function publishRootApi(
482557

483558
// PUT the API resource to APIM
484559
try {
485-
await client.putResource(context, deployedDescriptor, json);
560+
await client.putResource(context, options?.putDescriptor ?? deployedDescriptor, json);
486561
} catch (error) {
487562
throw new ApiPutAttemptError(error);
488563
}
@@ -525,7 +600,13 @@ async function publishApiRevisions(
525600
// Publish each revision in order
526601
const results: ResourcePublishResult[] = [];
527602
for (const revDescriptor of revisionDescriptors) {
528-
results.push(await publishResource(client, store, context, revDescriptor, config));
603+
const result = await publishResource(client, store, context, revDescriptor, config);
604+
if (result.status === 'failed') {
605+
throw new Error(
606+
`Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ${result.error?.message ?? 'unknown error'}`
607+
);
608+
}
609+
results.push(result);
529610
}
530611

531612
return results;

‎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
*/

‎src/services/dry-run-reporter.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { getTopologicalOrder } from '../lib/dependency-graph.js';
1515
import { buildResourceLabel } from '../lib/resource-uri.js';
1616
import { getResourceDescriptorKey } from '../lib/resource-path.js';
1717
import { logger } from '../lib/logger.js';
18-
import { computeDeleteActions } from './delete-unmatched-service.js';
18+
import {
19+
computeDeleteActions,
20+
filterRevisionDeletesHandledByBaseApi,
21+
} from './delete-unmatched-service.js';
1922
import { applyOverrides } from './override-merger.js';
2023
import {
2124
evaluateResourceEligibility,
@@ -156,8 +159,10 @@ export async function generateDryRunReport(
156159

157160
// In incremental mode, use precomputed deleted descriptors from git diff.
158161
// Otherwise, if delete-unmatched is enabled, calculate full unmatched deletes.
162+
// Apply the same ;rev=N filtering as the real delete path so the preview
163+
// matches what publish would actually delete.
159164
if (incrementalDeletedDescriptors.length > 0) {
160-
for (const descriptor of incrementalDeletedDescriptors) {
165+
for (const descriptor of filterRevisionDeletesHandledByBaseApi(incrementalDeletedDescriptors)) {
161166
try {
162167
const deployedDescriptor = config.envMapping
163168
? mapDescriptor(descriptor, config.envMapping)
@@ -212,12 +217,14 @@ export async function generateDryRunReport(
212217
}
213218
}
214219
} else if (config.deleteUnmatched && !config.commitId) {
215-
const deleteActions = await computeDeleteActionsForDryRun(
216-
client,
217-
store,
218-
context,
219-
config,
220-
targetDescriptors
220+
const deleteActions = filterRevisionDeletesHandledByBaseApi(
221+
await computeDeleteActionsForDryRun(
222+
client,
223+
store,
224+
context,
225+
config,
226+
targetDescriptors
227+
)
221228
);
222229

223230
for (const descriptor of deleteActions) {

0 commit comments

Comments
 (0)