Skip to content

Commit 85ee4e2

Browse files
authored
Merge pull request #319 from Alexey-Zheltov/fix/transitive-named-value-displayname
fix(extract): resolve {{displayName}} named value references to resource names
2 parents fb157de + cbdf72e commit 85ee4e2

2 files changed

Lines changed: 332 additions & 6 deletions

File tree

‎src/services/transitive-extractor.ts‎

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import type { IApimClient } from '../clients/iapim-client.js';
55
import type { IArtifactStore } from '../clients/iartifact-store.js';
66
import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
7+
import { ResourceType } from '../models/resource-types.js';
78
import { getResourceDescriptorKey } from '../lib/resource-path.js';
89
import { buildResourceLabel } from '../lib/resource-uri.js';
910
import { logger } from '../lib/logger.js';
@@ -23,6 +24,69 @@ export interface TransitiveExtractionResult {
2324
errorCount: number;
2425
}
2526

27+
interface TransitiveTaskResult {
28+
dep: ResourceDescriptor;
29+
json?: Record<string, unknown>;
30+
/** Resolved to a resource already extracted under its real name. */
31+
skipped?: boolean;
32+
}
33+
34+
/**
35+
* Per-context (keyed by baseUrl) index of named value displayName → resource name.
36+
* Stored as promises so concurrent tasks share a single LIST call.
37+
*/
38+
type NamedValueNameIndex = Map<string, Promise<Map<string, string>>>;
39+
40+
async function loadNamedValueNames(
41+
client: IApimClient,
42+
context: ApimServiceContext
43+
): Promise<Map<string, string>> {
44+
const byDisplayName = new Map<string, string>();
45+
try {
46+
for await (const json of client.listResources(context, ResourceType.NamedValue)) {
47+
const name = json.name;
48+
const displayName = (json.properties as Record<string, unknown> | undefined)?.displayName;
49+
if (typeof name === 'string' && typeof displayName === 'string') {
50+
byDisplayName.set(displayName, name);
51+
}
52+
}
53+
} catch (error) {
54+
const message = error instanceof Error ? error.message : String(error);
55+
logger.warn(
56+
`Failed to list named values for display name resolution; ` +
57+
`falling back to resolving {{references}} as resource names: ${message}`
58+
);
59+
}
60+
return byDisplayName;
61+
}
62+
63+
/**
64+
* Policies reference named values as {{displayName}}, but the ARM GET endpoint
65+
* is keyed by resource name. Map the reference to the resource name when they
66+
* differ; otherwise return the descriptor unchanged.
67+
*/
68+
async function resolveNamedValueDescriptor(
69+
client: IApimClient,
70+
context: ApimServiceContext,
71+
dep: ResourceDescriptor,
72+
index: NamedValueNameIndex
73+
): Promise<ResourceDescriptor> {
74+
let names = index.get(context.baseUrl);
75+
if (!names) {
76+
names = loadNamedValueNames(client, context);
77+
index.set(context.baseUrl, names);
78+
}
79+
80+
const ref = dep.nameParts[0] ?? '';
81+
const name = (await names).get(ref);
82+
if (!name || name === ref) {
83+
return dep;
84+
}
85+
86+
logger.debug(`Resolved named value reference "{{${ref}}}" to resource name "${name}"`);
87+
return { ...dep, nameParts: [name] };
88+
}
89+
2690
export async function extractTransitiveDependencies(
2791
client: IApimClient,
2892
store: IArtifactStore,
@@ -36,7 +100,9 @@ export async function extractTransitiveDependencies(
36100
serviceContext?: ApimServiceContext
37101
): Promise<TransitiveExtractionResult> {
38102
const attempted = new Set(alreadyExtracted.map(getResourceDescriptorKey));
103+
const claimedNamedValues = new Set(alreadyExtracted.map(getResourceDescriptorKey));
39104
const extractedDescriptors: ResourceDescriptor[] = [];
105+
const namedValueIndex: NamedValueNameIndex = new Map();
40106
let errorCount = 0;
41107
let foundDependencies = false;
42108

@@ -61,17 +127,27 @@ export async function extractTransitiveDependencies(
61127
attempted.add(getResourceDescriptorKey(dep));
62128
}
63129

64-
const tasks = newDeps.map((dep) => async () => {
130+
const tasks = newDeps.map((dep) => async (): Promise<TransitiveTaskResult> => {
65131
try {
66132
const dependencyContext =
67133
serviceContext && dep.workspace !== workspace ? serviceContext : context;
68-
const json = await client.getResource(dependencyContext, dep);
134+
let target = dep;
135+
if (dep.type === ResourceType.NamedValue) {
136+
target = await resolveNamedValueDescriptor(client, dependencyContext, dep, namedValueIndex);
137+
const targetKey = getResourceDescriptorKey(target);
138+
if (claimedNamedValues.has(targetKey)) {
139+
return { dep: target, skipped: true };
140+
}
141+
claimedNamedValues.add(targetKey);
142+
}
143+
const json = await client.getResource(dependencyContext, target);
69144
if (json) {
70-
const safeJson = redactSecrets(dep, json);
71-
await store.writeResource(outputDir, dep, safeJson);
72-
logger.info(`Extracted transitive dependency ${buildResourceLabel(dep)}`);
73-
return { dep, json: safeJson };
145+
const safeJson = redactSecrets(target, json);
146+
await store.writeResource(outputDir, target, safeJson);
147+
logger.info(`Extracted transitive dependency ${buildResourceLabel(target)}`);
148+
return { dep: target, json: safeJson };
74149
}
150+
logger.warn(`Transitive dependency ${buildResourceLabel(target)} was not found`);
75151
} catch (error) {
76152
const message = error instanceof Error ? error.message : String(error);
77153
logger.warn(
@@ -84,6 +160,9 @@ export async function extractTransitiveDependencies(
84160
const taskResults = await runParallel(tasks, DEFAULT_CONCURRENCY);
85161
for (const taskResult of taskResults) {
86162
const value = taskResult.status === 'fulfilled' ? taskResult.value : undefined;
163+
if (value?.skipped) {
164+
continue;
165+
}
87166
if (!value?.json) {
88167
errorCount++;
89168
continue;
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
/**
4+
* Unit tests for transitive dependency extraction
5+
*/
6+
7+
import { describe, it, expect, vi } from 'vitest';
8+
import { ResourceType } from '../../../src/models/resource-types.js';
9+
import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/types.js';
10+
import { extractTransitiveDependencies } from '../../../src/services/transitive-extractor.js';
11+
12+
const testContext: ApimServiceContext = {
13+
subscriptionId: 'sub-1',
14+
resourceGroup: 'rg-1',
15+
serviceName: 'apim-1',
16+
apiVersion: '2024-05-01',
17+
baseUrl: 'https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.ApiManagement/service/apim-1',
18+
};
19+
20+
function createMockClient() {
21+
return {
22+
listResources: vi.fn(async function* (_ctx: ApimServiceContext, _type: ResourceType): AsyncGenerator<Record<string, unknown>> {}),
23+
getResource: vi.fn().mockResolvedValue(undefined),
24+
putResource: vi.fn(),
25+
patchResource: vi.fn(),
26+
deleteResource: vi.fn(),
27+
listApiRevisions: async function* () {},
28+
getApiSpecification: vi.fn().mockResolvedValue(undefined),
29+
validatePreFlight: vi.fn().mockResolvedValue(undefined),
30+
};
31+
}
32+
33+
function createMockStore() {
34+
return {
35+
writeResource: vi.fn().mockResolvedValue(undefined),
36+
writeContent: vi.fn().mockResolvedValue(undefined),
37+
writeAssociation: vi.fn().mockResolvedValue(undefined),
38+
readResource: vi.fn().mockResolvedValue(undefined),
39+
readContent: vi.fn().mockResolvedValue(undefined),
40+
readAssociation: vi.fn().mockResolvedValue([]),
41+
listResources: vi.fn().mockResolvedValue([]),
42+
deleteResource: vi.fn().mockResolvedValue(undefined),
43+
};
44+
}
45+
46+
function policiesWith(...refs: string[]): Map<string, string> {
47+
const xml = refs.map((ref) => `<set-variable name="v" value="{{${ref}}}" />`).join('');
48+
return new Map([['api-1', `<policies><inbound>${xml}</inbound></policies>`]]);
49+
}
50+
51+
function namedValueList(...entries: Array<{ name: string; displayName: string }>) {
52+
return vi.fn(async function* (_ctx: ApimServiceContext, type: ResourceType) {
53+
if (type === ResourceType.NamedValue) {
54+
for (const entry of entries) {
55+
yield { name: entry.name, properties: { displayName: entry.displayName } };
56+
}
57+
}
58+
});
59+
}
60+
61+
describe('transitive-extractor', () => {
62+
describe('named value displayName resolution (#291)', () => {
63+
it('should resolve {{displayName}} to the resource name and extract under that name', async () => {
64+
const client = createMockClient();
65+
client.listResources = namedValueList({ name: 'nv-resource', displayName: 'nv_display' });
66+
client.getResource.mockImplementation(async (_ctx, descriptor: ResourceDescriptor) => {
67+
if (descriptor.type === ResourceType.NamedValue && descriptor.nameParts[0] === 'nv-resource') {
68+
return { name: 'nv-resource', properties: { displayName: 'nv_display', secret: false, value: 'x' } };
69+
}
70+
return undefined;
71+
});
72+
const store = createMockStore();
73+
74+
const result = await extractTransitiveDependencies(
75+
client, store, testContext, '/output', policiesWith('nv_display'), new Map(), [], []
76+
);
77+
78+
expect(result.errorCount).toBe(0);
79+
expect(result.extractedDescriptors).toEqual([
80+
expect.objectContaining({ type: ResourceType.NamedValue, nameParts: ['nv-resource'] }),
81+
]);
82+
expect(client.getResource).not.toHaveBeenCalledWith(
83+
testContext,
84+
expect.objectContaining({ nameParts: ['nv_display'] })
85+
);
86+
expect(store.writeResource).toHaveBeenCalledWith(
87+
'/output',
88+
expect.objectContaining({ type: ResourceType.NamedValue, nameParts: ['nv-resource'] }),
89+
expect.objectContaining({ name: 'nv-resource' })
90+
);
91+
});
92+
93+
it('should fall back to the reference as resource name when no displayName matches', async () => {
94+
const client = createMockClient();
95+
client.listResources = namedValueList({ name: 'other', displayName: 'other' });
96+
client.getResource.mockImplementation(async (_ctx, descriptor: ResourceDescriptor) => {
97+
if (descriptor.type === ResourceType.NamedValue && descriptor.nameParts[0] === 'plain-ref') {
98+
return { name: 'plain-ref', properties: { displayName: 'plain-ref' } };
99+
}
100+
return undefined;
101+
});
102+
const store = createMockStore();
103+
104+
const result = await extractTransitiveDependencies(
105+
client, store, testContext, '/output', policiesWith('plain-ref'), new Map(), [], []
106+
);
107+
108+
expect(result.errorCount).toBe(0);
109+
expect(result.extractedDescriptors).toEqual([
110+
expect.objectContaining({ type: ResourceType.NamedValue, nameParts: ['plain-ref'] }),
111+
]);
112+
});
113+
114+
it('should list named values once per context across multiple references', async () => {
115+
const client = createMockClient();
116+
client.listResources = namedValueList(
117+
{ name: 'nv-a', displayName: 'A' },
118+
{ name: 'nv-b', displayName: 'B' },
119+
{ name: 'nv-c', displayName: 'C' },
120+
);
121+
client.getResource.mockImplementation(async (_ctx, descriptor: ResourceDescriptor) => ({
122+
name: descriptor.nameParts[0],
123+
properties: {},
124+
}));
125+
const store = createMockStore();
126+
127+
const result = await extractTransitiveDependencies(
128+
client, store, testContext, '/output', policiesWith('A', 'B', 'C'), new Map(), [], []
129+
);
130+
131+
expect(result.errorCount).toBe(0);
132+
expect(result.extractedDescriptors.map((d) => d.nameParts[0]).sort()).toEqual(['nv-a', 'nv-b', 'nv-c']);
133+
const namedValueLists = client.listResources.mock.calls.filter(
134+
([, type]) => type === ResourceType.NamedValue
135+
);
136+
expect(namedValueLists).toHaveLength(1);
137+
});
138+
139+
it('should extract each resolved resource when a resource name is also another display name', async () => {
140+
const client = createMockClient();
141+
client.listResources = namedValueList(
142+
{ name: 'one', displayName: 'two' },
143+
{ name: 'two', displayName: 'three' },
144+
);
145+
client.getResource.mockImplementation(async (_ctx, descriptor: ResourceDescriptor) => ({
146+
name: descriptor.nameParts[0],
147+
properties: {},
148+
}));
149+
const store = createMockStore();
150+
151+
const result = await extractTransitiveDependencies(
152+
client, store, testContext, '/output', policiesWith('two', 'three'), new Map(), [], []
153+
);
154+
155+
expect(result.errorCount).toBe(0);
156+
expect(result.extractedDescriptors.map((descriptor) => descriptor.nameParts[0]).sort()).toEqual([
157+
'one',
158+
'two',
159+
]);
160+
expect(client.getResource).toHaveBeenCalledTimes(2);
161+
});
162+
163+
it('should not re-extract or count an error when the resolved name was already extracted', async () => {
164+
const client = createMockClient();
165+
client.listResources = namedValueList({ name: 'nv-resource', displayName: 'nv_display' });
166+
const store = createMockStore();
167+
const alreadyExtracted: ResourceDescriptor[] = [
168+
{ type: ResourceType.NamedValue, nameParts: ['nv-resource'] },
169+
];
170+
171+
const result = await extractTransitiveDependencies(
172+
client, store, testContext, '/output', policiesWith('nv_display'), new Map(), [], alreadyExtracted
173+
);
174+
175+
expect(result.errorCount).toBe(0);
176+
expect(result.extractedDescriptors).toEqual([]);
177+
expect(client.getResource).not.toHaveBeenCalled();
178+
expect(store.writeResource).not.toHaveBeenCalled();
179+
});
180+
181+
it('should fall back to the reference when listing named values fails', async () => {
182+
const client = createMockClient();
183+
// eslint-disable-next-line require-yield
184+
client.listResources = vi.fn(async function* (): AsyncGenerator<Record<string, unknown>> {
185+
throw new Error('403 Forbidden');
186+
});
187+
client.getResource.mockImplementation(async (_ctx, descriptor: ResourceDescriptor) => {
188+
if (descriptor.nameParts[0] === 'nv_display') {
189+
return { name: 'nv_display', properties: {} };
190+
}
191+
return undefined;
192+
});
193+
const store = createMockStore();
194+
195+
const result = await extractTransitiveDependencies(
196+
client, store, testContext, '/output', policiesWith('nv_display'), new Map(), [], []
197+
);
198+
199+
expect(result.errorCount).toBe(0);
200+
expect(result.extractedDescriptors).toEqual([
201+
expect.objectContaining({ type: ResourceType.NamedValue, nameParts: ['nv_display'] }),
202+
]);
203+
});
204+
205+
it('should resolve workspace-scoped references against the workspace context', async () => {
206+
const wsContext: ApimServiceContext = {
207+
...testContext,
208+
baseUrl: `${testContext.baseUrl}/workspaces/team-a`,
209+
};
210+
const client = createMockClient();
211+
client.listResources = vi.fn(async function* (ctx: ApimServiceContext, type: ResourceType) {
212+
if (type === ResourceType.NamedValue && ctx.baseUrl === wsContext.baseUrl) {
213+
yield { name: 'ws-nv', properties: { displayName: 'ws_display' } };
214+
}
215+
});
216+
client.getResource.mockImplementation(async (ctx: ApimServiceContext, descriptor: ResourceDescriptor) => {
217+
if (ctx.baseUrl === wsContext.baseUrl && descriptor.nameParts[0] === 'ws-nv') {
218+
return { name: 'ws-nv', properties: {} };
219+
}
220+
return undefined;
221+
});
222+
const store = createMockStore();
223+
224+
const result = await extractTransitiveDependencies(
225+
client, store, wsContext, '/output', policiesWith('ws_display'), new Map(), [], [], 'team-a', testContext
226+
);
227+
228+
expect(result.errorCount).toBe(0);
229+
expect(result.extractedDescriptors).toEqual([
230+
expect.objectContaining({ type: ResourceType.NamedValue, nameParts: ['ws-nv'], workspace: 'team-a' }),
231+
]);
232+
expect(client.listResources).not.toHaveBeenCalledWith(testContext, ResourceType.NamedValue);
233+
});
234+
235+
it('should count a missing dependency as an error', async () => {
236+
const client = createMockClient();
237+
const store = createMockStore();
238+
239+
const result = await extractTransitiveDependencies(
240+
client, store, testContext, '/output', policiesWith('missing'), new Map(), [], []
241+
);
242+
243+
expect(result.errorCount).toBe(1);
244+
expect(result.extractedDescriptors).toEqual([]);
245+
});
246+
});
247+
});

0 commit comments

Comments
 (0)