Skip to content

Commit 4679b3e

Browse files
author
Alexander Zaslonov
committed
fix(gateway): address review feedback
1 parent 2756791 commit 4679b3e

9 files changed

Lines changed: 225 additions & 21 deletions

‎src/clients/apim-client.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export class HttpError extends Error {
2424
constructor(
2525
public readonly status: number,
2626
message: string,
27-
public readonly code?: string // APIM error code, e.g. "MethodNotAllowedInPricingTier"
27+
public readonly code?: string, // APIM error code, e.g. "MethodNotAllowedInPricingTier"
28+
public readonly body?: unknown
2829
) {
2930
super(message);
3031
this.name = 'HttpError';
@@ -41,6 +42,26 @@ export function isLinkAlreadyExistsError(error: unknown): boolean {
4142
return error instanceof HttpError && error.status === 409;
4243
}
4344

45+
/** Returns true when APIM reports that an association's referenced API/group is absent. */
46+
export function isAssociationReferenceNotFoundError(error: unknown): boolean {
47+
if (
48+
!(error instanceof HttpError) ||
49+
![400, 404].includes(error.status) ||
50+
!['ValidationError', 'ResourceNotFound'].includes(error.code ?? '')
51+
) {
52+
return false;
53+
}
54+
55+
const body = error.body as Record<string, unknown> | undefined;
56+
const armError = body?.error as Record<string, unknown> | undefined;
57+
const details = Array.isArray(armError?.details) ? armError.details : [];
58+
return details.some((detail) => {
59+
if (typeof detail !== 'object' || detail === null) return false;
60+
const target = (detail as Record<string, unknown>).target;
61+
return typeof target === 'string' && ['aid', 'gid'].includes(target.toLowerCase());
62+
});
63+
}
64+
4465
export class ApimClient implements IApimClient {
4566
private credential: DefaultAzureCredential;
4667
private readonly authScope: string;
@@ -187,8 +208,9 @@ export class ApimClient implements IApimClient {
187208
if (!response.ok) {
188209
const errorText = await response.text();
189210
let errorCode: string | undefined;
211+
let errorBody: unknown;
190212
try {
191-
const errorBody: unknown = JSON.parse(errorText);
213+
errorBody = JSON.parse(errorText);
192214
if (
193215
typeof errorBody === 'object' && errorBody !== null &&
194216
'error' in errorBody &&
@@ -202,7 +224,7 @@ export class ApimClient implements IApimClient {
202224
} catch {
203225
// Response body is not JSON — no error code available
204226
}
205-
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode);
227+
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`, errorCode, errorBody);
206228
}
207229

208230
return response;

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
isApiRevisionName,
3737
} from '../lib/resource-path.js';
3838
import { logger } from '../lib/logger.js';
39-
import { toCanonicalDescriptor } from './env-mapper.js';
39+
import { mapDescriptor, toCanonicalDescriptor, toCanonicalName } from './env-mapper.js';
4040

4141
/**
4242
* Drop ;rev=N API deletes whose base API (same workspace) is also queued for
@@ -243,6 +243,9 @@ async function computeGatewayApiDeleteActions(
243243
type: ResourceType.Gateway,
244244
nameParts: [gatewayName],
245245
};
246+
const deployedGatewayDescriptor = envMapping !== undefined
247+
? mapDescriptor(gatewayDescriptor, envMapping)
248+
: gatewayDescriptor;
246249

247250
// Desired API set (canonical names) from the gateway's apis.json artifact.
248251
let desiredApis: Set<string>;
@@ -260,7 +263,7 @@ async function computeGatewayApiDeleteActions(
260263
for await (const apiJson of client.listResources(
261264
context,
262265
ResourceType.GatewayApi,
263-
gatewayDescriptor
266+
deployedGatewayDescriptor
264267
)) {
265268
const apiName = extractResourceName(apiJson);
266269
if (!apiName) {
@@ -269,7 +272,7 @@ async function computeGatewayApiDeleteActions(
269272

270273
const deployedDescriptor: ResourceDescriptor = {
271274
type: ResourceType.GatewayApi,
272-
nameParts: [gatewayName, apiName],
275+
nameParts: [getNamePart(deployedGatewayDescriptor.nameParts, 0), apiName],
273276
};
274277

275278
// Compare the deployed API against the desired set using canonical names
@@ -281,7 +284,13 @@ async function computeGatewayApiDeleteActions(
281284
// Belongs to another environment — do not touch.
282285
continue;
283286
}
284-
canonicalApiName = getNamePart(canonicalDescriptor.nameParts, 1);
287+
const canonicalChildName = toCanonicalName(apiName, ResourceType.Api, envMapping);
288+
if (canonicalChildName === undefined) {
289+
// The gateway can be shared (for example, "managed"), so the child
290+
// API must independently belong to this environment's namespace.
291+
continue;
292+
}
293+
canonicalApiName = canonicalChildName;
285294
}
286295

287296
if (!desiredApis.has(canonicalApiName)) {

‎src/services/env-mapper.ts‎

Lines changed: 8 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

4-
import { ResourceType } from '../models/resource-types.js';
4+
import { MANAGED_GATEWAY_NAME, ResourceType } from '../models/resource-types.js';
55
import type { ResourceDescriptor } from '../models/types.js';
66
import type { EnvironmentOverride, OverrideConfig } from '../models/config.js';
77

@@ -180,7 +180,12 @@ function splitRevisionSuffix(name: string, type: ResourceType): { base: string;
180180
: { base: name.slice(0, idx), revSuffix: name.slice(idx) };
181181
}
182182

183+
function isManagedGatewayName(name: string, type: ResourceType): boolean {
184+
return type === ResourceType.Gateway && name === MANAGED_GATEWAY_NAME;
185+
}
186+
183187
export function toDeployedName(name: string, type: ResourceType, m: EnvMapping): string {
188+
if (isManagedGatewayName(name, type)) return name;
184189
if (!m.appliesTo.has(type)) return name;
185190
const { base, revSuffix } = splitRevisionSuffix(name, type);
186191
return `${m.prefix}${base}${m.suffix}${revSuffix}`;
@@ -192,6 +197,7 @@ export function toDeployedName(name: string, type: ResourceType, m: EnvMapping):
192197
* Returns input unchanged when type ∉ appliesTo.
193198
*/
194199
export function toCanonicalName(deployedName: string, type: ResourceType, m: EnvMapping): string | undefined {
200+
if (isManagedGatewayName(deployedName, type)) return deployedName;
195201
if (!m.appliesTo.has(type)) return deployedName;
196202
if (!isInEnvNamespace(deployedName, type, m)) return undefined;
197203

@@ -207,6 +213,7 @@ export function toCanonicalName(deployedName: string, type: ResourceType, m: Env
207213
* When type ∉ appliesTo → returns true (namespace scoping doesn't apply to this type).
208214
*/
209215
export function isInEnvNamespace(deployedName: string, type: ResourceType, m: EnvMapping): boolean {
216+
if (isManagedGatewayName(deployedName, type)) return true;
210217
if (!m.appliesTo.has(type)) return true;
211218
const { base } = splitRevisionSuffix(deployedName, type);
212219
if (base.length < m.prefix.length + m.suffix.length) return false;

‎src/services/extract-service.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ async function extractGatewayAssociations(
439439
store: IArtifactStore,
440440
context: ApimServiceContext,
441441
outputDir: string,
442-
_filter: FilterConfig | undefined,
442+
filter: FilterConfig | undefined,
443443
result: ExtractionResult
444444
): Promise<void> {
445445
const gatewayResults = result.typeResults.filter((r) => r.type === ResourceType.Gateway);
@@ -478,6 +478,10 @@ async function extractGatewayAssociations(
478478
type: ResourceType.Gateway,
479479
nameParts: [MANAGED_GATEWAY_NAME],
480480
};
481+
if (!shouldIncludeResource(managedDescriptor, filter)) {
482+
return;
483+
}
484+
481485
try {
482486
const apiNames: string[] = [];
483487
for await (const apiJson of client.listResources(context, ResourceType.GatewayApi, managedDescriptor)) {
@@ -486,11 +490,8 @@ async function extractGatewayAssociations(
486490
apiNames.push(name);
487491
}
488492
}
489-
// Only record the managed gateway when it actually has assignments. An empty
490-
// apis.json would neither help publish nor delete-unmatched reconciliation
491-
// (which scopes to gateways that own at least one local assignment).
493+
await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames);
492494
if (apiNames.length > 0) {
493-
await store.writeAssociation(outputDir, managedDescriptor, 'apis', apiNames);
494495
result.totalExtracted++;
495496
logger.info(`Extracted ${apiNames.length} API associations for gateway "${MANAGED_GATEWAY_NAME}"`);
496497
}

‎src/services/resource-publisher.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { isAutoGeneratedId } from '../lib/auto-generated.js';
1919
import { isWorkspaceScope, buildLinkPayload } from '../lib/workspace-link.js';
2020
import { logger } from '../lib/logger.js';
2121
import { REDACTION_MARKER } from './secret-redactor.js';
22-
import { isLinkAlreadyExistsError } from '../clients/apim-client.js';
22+
import { isAssociationReferenceNotFoundError, isLinkAlreadyExistsError } from '../clients/apim-client.js';
2323
import type { OverrideConfig, OverrideSection } from '../models/config.js';
2424
import { buildResourceLabel } from '../lib/resource-uri.js';
2525
import { mapDescriptor, toDeployedName } from './env-mapper.js';
@@ -540,8 +540,7 @@ async function publishAssociation(
540540
// The referenced API/group is absent on the target (filtered out or
541541
// failed to publish). Skip this single link with a warning instead of
542542
// aborting the whole association, so other present entries still link.
543-
const message = error instanceof Error ? error.message : String(error);
544-
if (message.includes('API not found') || message.includes('Group not found')) {
543+
if (isAssociationReferenceNotFoundError(error)) {
545544
logger.warn(
546545
`Skipping ${associationType} association '${entry.name}' on ` +
547546
`'${getNamePart(descriptor.nameParts, 0)}': referenced resource not found on target`

‎tests/unit/services/delete-unmatched-service.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ResourceType } from '../../../src/models/resource-types.js';
1313
import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js';
1414
import { PublishConfig } from '../../../src/models/config.js';
1515
import { LogLevel } from '../../../src/lib/logger.js';
16+
import { buildEnvMapping } from '../../../src/services/env-mapper.js';
1617

1718
function createMockClient(apimResources: Map<ResourceType, Record<string, unknown>[]> = new Map()) {
1819
return {
@@ -277,6 +278,63 @@ describe('delete-unmatched-service', () => {
277278
expect(result.filter((d) => d.type === ResourceType.GatewayApi)).toHaveLength(0);
278279
});
279280

281+
it('should list custom gateway APIs using the deployed parent name', async () => {
282+
const localDescriptors: ResourceDescriptor[] = [
283+
{ type: ResourceType.GatewayApi, nameParts: ['custom-gateway'] },
284+
];
285+
const listedParents: Array<ResourceDescriptor | undefined> = [];
286+
const client = createMockClient();
287+
client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) {
288+
if (type === ResourceType.GatewayApi) {
289+
listedParents.push(parent);
290+
if (parent?.nameParts[0] === 'dev-custom-gateway') {
291+
yield { name: 'dev-api-stale' };
292+
}
293+
}
294+
};
295+
const store = createMockStore(localDescriptors);
296+
store.readAssociation = vi.fn().mockResolvedValue([]);
297+
const envMapping = buildEnvMapping({
298+
namePrefix: 'dev-',
299+
appliesTo: [ResourceType.Gateway, ResourceType.Api],
300+
});
301+
302+
const result = await computeDeleteActions(client, store, testContext, { ...testConfig, envMapping });
303+
304+
expect(listedParents).toContainEqual({
305+
type: ResourceType.Gateway,
306+
nameParts: ['dev-custom-gateway'],
307+
});
308+
expect(result).toContainEqual({
309+
type: ResourceType.GatewayApi,
310+
nameParts: ['dev-custom-gateway', 'dev-api-stale'],
311+
});
312+
});
313+
314+
it('should delete all in-scope managed gateway APIs for an empty desired set without touching other environments', async () => {
315+
const apimResources = new Map<ResourceType, Record<string, unknown>[]>([
316+
[ResourceType.GatewayApi, [
317+
{ name: 'dev-api-one' },
318+
{ name: 'dev-api-two' },
319+
{ name: 'prod-api' },
320+
]],
321+
]);
322+
const localDescriptors: ResourceDescriptor[] = [
323+
{ type: ResourceType.GatewayApi, nameParts: ['managed'] },
324+
];
325+
const client = createMockClient(apimResources);
326+
const store = createMockStore(localDescriptors);
327+
store.readAssociation = vi.fn().mockResolvedValue([]);
328+
const envMapping = buildEnvMapping({ namePrefix: 'dev-' });
329+
330+
const result = await computeDeleteActions(client, store, testContext, { ...testConfig, envMapping });
331+
332+
expect(result.filter((descriptor) => descriptor.type === ResourceType.GatewayApi)).toEqual([
333+
{ type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-one'] },
334+
{ type: ResourceType.GatewayApi, nameParts: ['managed', 'dev-api-two'] },
335+
]);
336+
});
337+
280338
it('should not touch any gateway assignments when artifacts track no gateways', async () => {
281339
const apimResources = new Map<ResourceType, Record<string, unknown>[]>([
282340
[ResourceType.GatewayApi, [{ name: 'api-a' }, { name: 'api-b' }]],

‎tests/unit/services/env-mapper.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
buildEnvMappingFromOverrides,
1313
toDeployedName,
1414
toCanonicalName,
15+
toCanonicalDescriptor,
1516
isInEnvNamespace,
1617
mapDescriptor,
1718
type EnvironmentOverride,
@@ -393,6 +394,21 @@ describe('env-mapper', () => {
393394
expect(result).toEqual({ type: ResourceType.GatewayApi, nameParts: ['my-gw', 'dev-petstore'] });
394395
});
395396

397+
it('GatewayApi: preserves the managed gateway when Gateway is explicitly affixed', () => {
398+
const mapping = prefixMapping('dev-', [ResourceType.Gateway, ResourceType.Api]);
399+
const descriptor: ResourceDescriptor = { type: ResourceType.GatewayApi, nameParts: ['managed', 'petstore'] };
400+
401+
expect(mapDescriptor(descriptor, mapping)).toEqual({
402+
type: ResourceType.GatewayApi,
403+
nameParts: ['managed', 'dev-petstore'],
404+
});
405+
expect(toCanonicalDescriptor({
406+
type: ResourceType.GatewayApi,
407+
nameParts: ['managed', 'dev-petstore'],
408+
}, mapping)).toEqual(descriptor);
409+
expect(isInEnvNamespace('managed', ResourceType.Gateway, mapping)).toBe(true);
410+
});
411+
396412
it('ServicePolicy: no nameParts, returned unchanged', () => {
397413
const d: ResourceDescriptor = { type: ResourceType.ServicePolicy, nameParts: [] };
398414
expect(mapDescriptor(d, m)).toEqual(d);

‎tests/unit/services/extract-service.test.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -633,8 +633,7 @@ describe('extract-service', () => {
633633
);
634634
});
635635

636-
it('should not write a managed gateway association when it has no apis', async () => {
637-
// Empty managed gateway must not produce an artifact or inflate the count.
636+
it('should write an empty managed gateway association when it has no apis', async () => {
638637
const client = createMockClient();
639638
const store = createMockStore();
640639

@@ -645,13 +644,41 @@ describe('extract-service', () => {
645644
logLevel: LogLevel.INFO,
646645
});
647646

647+
expect(store.writeAssociation).toHaveBeenCalledWith(
648+
expect.anything(),
649+
expect.objectContaining({ nameParts: ['managed'] }),
650+
'apis',
651+
[]
652+
);
653+
expect(result.totalExtracted).toBe(0);
654+
});
655+
656+
it('should not extract the managed gateway when excluded by the gateway filter', async () => {
657+
let managedGatewayListed = false;
658+
const client = createMockClient();
659+
client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType, parent?: ResourceDescriptor) {
660+
if (type === ResourceType.GatewayApi && parent?.nameParts[0] === 'managed') {
661+
managedGatewayListed = true;
662+
yield { name: 'managed-api' };
663+
}
664+
};
665+
const store = createMockStore();
666+
667+
await runExtraction(client, store, {
668+
service: testContext,
669+
outputDir: '/output',
670+
filter: { gateways: [] },
671+
includeTransitive: false,
672+
logLevel: LogLevel.INFO,
673+
});
674+
675+
expect(managedGatewayListed).toBe(false);
648676
expect(store.writeAssociation).not.toHaveBeenCalledWith(
649677
expect.anything(),
650678
expect.objectContaining({ nameParts: ['managed'] }),
651679
'apis',
652680
expect.anything()
653681
);
654-
expect(result.totalExtracted).toBe(0);
655682
});
656683

657684
it('should handle gateway association extraction error gracefully', async () => {

0 commit comments

Comments
 (0)