Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
82ad905
Initial plan
Copilot Aug 31, 2026
27ac24c
Initial plan
Copilot Aug 31, 2026
b0af0ef
fix: retry pessimistic concurrency conflicts (Fixes #252)
Copilot Aug 31, 2026
3cc53d8
fix: publish APIs before products (Closes #101)
Copilot Aug 31, 2026
f4a34d7
Merge branch 'test-productapi-race' into test-combined
Alexey-Zheltov Sep 3, 2026
bd433ae
chore: bump fast-uri to 3.1.7 (npm audit fix)
Alexey-Zheltov Sep 3, 2026
4eb4e77
fix: revision delete/create conflicts, in-use fragment skip, auth set…
Alexey-Zheltov Sep 3, 2026
7621640
fix: apply revision-delete filtering to dry runs and preserve workspa…
Alexey-Zheltov Sep 3, 2026
08fc89f
fix: prevent revision description inheritance from current revision
Alexey-Zheltov Sep 3, 2026
90a31a3
fix: address review - env-mapped revision targets and override-aware …
Alexey-Zheltov Sep 3, 2026
4a6c80e
fix: use deployed descriptor consistently for all direct APIM calls i…
Alexey-Zheltov Sep 3, 2026
cb8049e
fix: revision-aware env mapping and representation-aware auth overrid…
Alexey-Zheltov Sep 3, 2026
6b731ff
fix: env-mapped op reconcile, revision-only env suffix, auth override…
Alexey-Zheltov Sep 4, 2026
67f3c8b
fix: env-map MCP tool operationIds on root PUT and return only publis…
Alexey-Zheltov Sep 4, 2026
956381d
fix: use full revision name for description-override warning (review)
Alexey-Zheltov Sep 4, 2026
4cc4e16
fix: skip missing API/group links in gateway/product associations ins…
Alexey-Zheltov Sep 4, 2026
fde93c6
feat(gateway): reconcile managed gateway API assignments
Alexey-Zheltov Sep 4, 2026
7eb507f
fix(gateway): read desired apis from apis.json for delete-unmatched
Alexey-Zheltov Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 33 additions & 4 deletions src/clients/apim-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,18 @@ export class ApimClient implements IApimClient {

return response;
} catch (error) {
// Do not retry client errors (4xx) — they are deterministic, not transient.
// 429 rate-limiting is already handled above and never reaches here.
if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
// APIM reports operations blocked by an API's in-progress async operation
// as a transient 409. Other client errors are deterministic.
const isPessimisticConcurrencyConflict =
error instanceof HttpError &&
error.status === 409 &&
error.code === 'PessimisticConcurrencyConflict';
if (
error instanceof HttpError &&
error.status >= 400 &&
error.status < 500 &&
!isPessimisticConcurrencyConflict
) {
throw error;
}
if (attempt >= ApimClient.MAX_RETRIES) {
Expand Down Expand Up @@ -454,7 +463,18 @@ export class ApimClient implements IApimClient {
context: ApimServiceContext,
descriptor: ResourceDescriptor
): Promise<boolean> {
const url = buildArmUri(context, descriptor);
let url = buildArmUri(context, descriptor);

// Deleting an API that has revisions requires deleteRevisions=true; without it
// APIM refuses the base (current-revision) delete with "Cannot delete the
// current revision of an API." This also removes all revisions in one call,
// so callers skip the individual ;rev=N deletes.
if (
descriptor.type === ResourceType.Api &&
!(descriptor.nameParts[0] ?? '').includes(';rev=')
) {
url += '&deleteRevisions=true';
}

for (let attempt = 1; ; attempt++) {
try {
Expand Down Expand Up @@ -482,6 +502,15 @@ export class ApimClient implements IApimClient {
if (message.includes('404')) {
return false;
}
// Resource is still referenced by another entity (e.g. a policy fragment
// used by the service policy). It cannot be deleted until the reference is
// removed; skip it with a warning instead of failing the whole prune.
if (message.includes('is used by the following entities')) {
logger.warn(
`Skipping delete of ${buildResourceLabel(descriptor)}: still referenced by another entity`
);
return false;
}
// Transient optimistic-concurrency conflict: cascade deletes of related
// resources (subscriptions, product/gateway associations) can modify
// the resource while its async DELETE is in flight. Retry the DELETE.
Expand Down
10 changes: 10 additions & 0 deletions src/lib/resource-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ export function getNamePart(nameParts: string[], index: number): string {
return value;
}

/** True when an API name carries a revision suffix (e.g. "my-api;rev=2"). */
export function isApiRevisionName(apiName: string): boolean {
return apiName.includes(';rev=');
}

/** Root API name with any ";rev=N" suffix stripped. */
export function getApiRootName(apiName: string): string {
return apiName.split(';rev=')[0] ?? apiName;
}

/**
* Converts a positional template string to a capturing regex.
* Each `{i}` placeholder becomes a `([^/]+)` capture group; all other
Expand Down
7 changes: 7 additions & 0 deletions src/models/resource-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export enum ResourceType {
Workspace = 'Workspace',
}

/**
* The built-in "managed" gateway id. It is not returned by `GET /gateways`
* (which lists only self-hosted/custom gateways), but its per-API assignments
* are queryable/manageable at `gateways/managed/apis`.
*/
export const MANAGED_GATEWAY_NAME = 'managed';

/**
* Pure-data descriptor for a single APIM resource type.
*
Expand Down
6 changes: 4 additions & 2 deletions src/services/api-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,10 @@ async function extractApiRevisions(
for await (const revision of revisions) {
try {
const revNumber = (revision.apiRevision ?? revision.revisionNumber) as string | undefined;
if (!revNumber || revNumber === '1') {
// Skip revision 1 — it's the main API
// Skip the current revision — it is represented by the main API folder.
// Using isCurrent (not a hard-coded '1') correctly handles APIs whose
// current revision is not revision 1.
if (!revNumber || revision.isCurrent === true) {
continue;
}

Expand Down
154 changes: 121 additions & 33 deletions src/services/api-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ import type { PublishConfig } from '../models/config.js';
import * as yaml from 'js-yaml';
import { ResourceType } from '../models/resource-types.js';
import {
normalizeApiAuthenticationSettings,
normalizeMcpToolOperationIds,
prefersLegacyAuthOverride,
publishResource,
type ResourcePublishResult,
} from './resource-publisher.js';
import { runParallel } from '../lib/parallel-runner.js';
import { applyOverrides } from './override-merger.js';
import { mapDescriptor } from './env-mapper.js';
import { logger } from '../lib/logger.js';
import { getNamePart, getPublishTier } from '../lib/resource-path.js';
import { isAutoGeneratedId } from '../lib/auto-generated.js';
Expand Down Expand Up @@ -54,8 +57,26 @@ export async function publishApi(
config: PublishConfig
): Promise<ResourcePublishResult> {
try {
// Step 1: Publish root API (with spec import if available)
const rootResult = await publishRootApi(client, store, context, descriptor, config);
// Deployed (env-mapped) base descriptor — derived once and used for every
// direct APIM call so canonical and affixed names can never diverge.
const deployedDescriptor = config.envMapping
? mapDescriptor(descriptor, config.envMapping)
: descriptor;

// Step 1: Publish root API (with spec import if available).
// On a fresh target the root is created at its source revision number so
// it cannot collide with ;rev=N revision artifacts.
const putDescriptor = await resolveRootApiPutDescriptor(
client,
store,
context,
descriptor,
deployedDescriptor,
config
Comment thread
azaslonov marked this conversation as resolved.
);
const rootResult = await publishRootApi(client, store, context, descriptor, config, {
putDescriptor,
});
if (rootResult.status !== 'success') {
return rootResult;
}
Expand All @@ -64,7 +85,7 @@ export async function publishApi(
await alignImportedOperationDescriptions(
client,
context,
descriptor,
deployedDescriptor,
rootResult.operationIdsWithNullDescription
);
}
Expand All @@ -75,13 +96,10 @@ export async function publishApi(
// Step 2b: Align root API only when source marks it as current.
// Source of truth is properties.isCurrent in root apiInformation.json.
if (publishedRevisionCount > 0 && rootResult.isCurrent === true) {
const alignResult = await alignActiveRevisionWithSource(
client,
store,
context,
descriptor,
config
);
const alignResult = await publishRootApi(client, store, context, descriptor, config, {
includeSpecification: false,
putDescriptor: deployedDescriptor,
});
if (alignResult.status !== 'success') {
return alignResult;
}
Expand Down Expand Up @@ -148,6 +166,49 @@ interface RootApiResult {

interface PublishRootApiOptions {
includeSpecification?: boolean;
/** Descriptor to PUT to (defaults to the artifact descriptor). Lets the root
* API be created at apis/{name};rev=N while still reading apis/{name} artifacts. */
putDescriptor?: ResourceDescriptor;
}

/**
* A plain root PUT creates a brand-new API as revision 1, which collides with
* a ;rev=1 revision artifact and silently absorbs it whenever the source's
* current revision number is > 1. When the API does not yet exist on the
* target, PUT the root at its true revision number (apis/{name};rev=N) instead.
* Existing APIs keep the plain root PUT — their current revision cannot be
* renumbered.
*
* Reads artifacts via the canonical descriptor; all APIM lookups and the
* returned PUT target use the deployed (env-mapped) descriptor, with ;rev=N
* appended after affixing so suffix mappings cannot corrupt it.
*/
async function resolveRootApiPutDescriptor(
client: IApimClient,
store: IArtifactStore,
context: ApimServiceContext,
descriptor: ResourceDescriptor,
deployedDescriptor: ResourceDescriptor,
config: PublishConfig
): Promise<ResourceDescriptor> {
const json = await store.readResource(config.sourceDir, descriptor);
const rev = (json?.properties as Record<string, unknown> | undefined)?.apiRevision;
if (typeof rev !== 'string' || rev === '' || rev === '1') {
return deployedDescriptor;
}

const existing = await client.getResource(context, deployedDescriptor);
if (existing) {
return deployedDescriptor;
}

return {
...deployedDescriptor,
nameParts: [
`${getNamePart(deployedDescriptor.nameParts, 0)};rev=${rev}`,
...deployedDescriptor.nameParts.slice(1),
],
};
}

/**
Expand Down Expand Up @@ -176,11 +237,18 @@ async function publishRootApi(

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

// Apply overrides
json = applyOverrides(descriptor, json, config.overrides);
json = normalizeApiAuthenticationSettings(json, {
preferLegacyFields: prefersLegacyAuthOverride(
getNamePart(descriptor.nameParts, 0),
config.overrides?.apis
),
});
const isCurrent = getApiIsCurrent(json);

// Try to read the specification file for this API
Expand Down Expand Up @@ -239,7 +307,7 @@ async function publishRootApi(
}

// PUT the API resource to APIM
await client.putResource(context, descriptor, json);
await client.putResource(context, options?.putDescriptor ?? descriptor, json);

return {
descriptor,
Expand All @@ -251,22 +319,6 @@ async function publishRootApi(
};
}

async function alignActiveRevisionWithSource(
client: IApimClient,
store: IArtifactStore,
context: ApimServiceContext,
descriptor: ResourceDescriptor,
config: PublishConfig
): Promise<RootApiResult & ResourcePublishResult> {
logger.debug(
`Source marks "${getNamePart(descriptor.nameParts, 0)}" as current; re-applying root metadata to align active revision`
);

return publishRootApi(client, store, context, descriptor, config, {
includeSpecification: false,
});
}

/**
* Find and publish API revisions in numeric order
*/
Expand Down Expand Up @@ -294,12 +346,41 @@ async function publishApiRevisions(
return revA - revB;
});

// Publish each revision in order
// The current revision is already published as the root API, so skip a
// revision artifact that carries the same number — otherwise we re-create /
// collide with it (e.g. the root PUT created at ;rev=N for a fresh
// multi-revision API, or a stale ;rev=N folder from an older extract).
const rootJson = await store.readResource(config.sourceDir, apiDescriptor);
const rootRevision = (rootJson?.properties as Record<string, unknown> | undefined)?.apiRevision;
const rootRevisionNumber =
typeof rootRevision === 'string' && rootRevision !== '' ? Number(rootRevision) : undefined;

// Publish each revision in order; a failed revision must fail the API —
// otherwise errors are silently swallowed and the exit code stays 0.
let publishedCount = 0;
for (const revDescriptor of sortedRevisions) {
await publishResource(client, store, context, revDescriptor, config);
if (
rootRevisionNumber !== undefined &&
extractRevisionNumber(getNamePart(revDescriptor.nameParts, 0)) === rootRevisionNumber
) {
logger.debug(
`Skipping revision ${getNamePart(revDescriptor.nameParts, 0)} — already published as the root API`
);
continue;
}
const result = await publishResource(client, store, context, revDescriptor, config);
if (result.status === 'failed') {
throw new Error(
`Failed to publish revision ${getNamePart(revDescriptor.nameParts, 0)}: ` +
`${result.error?.message ?? 'unknown error'}`
);
}
if (result.status === 'success') publishedCount++;
}

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

/**
Expand Down Expand Up @@ -467,8 +548,15 @@ async function reconcileOperationsAfterSpecImport(

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

// Artifact lookup above uses the canonical descriptor; the PATCH must target
// the deployed (env-mapped) name so reconciliation hits the API that the
// root create actually produced under environment mapping.
const patchDescriptor = config.envMapping
? mapDescriptor(descriptor, config.envMapping)
: descriptor;

try {
await client.patchResource(context, descriptor, patchBody);
await client.patchResource(context, patchDescriptor, patchBody);
logger.debug(`Reconciled operation "${getNamePart(descriptor.nameParts, 1)}" after spec import`);
} catch (error) {
logger.warn(
Expand Down
Loading