Skip to content

Commit 28b787d

Browse files
Peter HaugeCopilot
andcommitted
feat: implement workspace sub-resource filtering
Workspace sub-filters specified in workspaceSubFilters are now applied at runtime. Each workspace's sub-filter is converted to a FilterConfig and passed to the extraction pipeline. Wildcard patterns in workspace names trigger discovery-then-filter instead of direct fetch. Removes the "not yet implemented" warning from the filtering guide. Closes #119 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ea47dc commit 28b787d

3 files changed

Lines changed: 269 additions & 13 deletions

File tree

docs/guides/filtering-resources.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,6 @@ apis:
140140

141141
### Workspace sub-resource filters
142142

143-
> **Note:** Workspace sub-resource filtering is parsed but not yet applied at runtime. Currently, including a workspace extracts all resources within it. This matches the Toolkit's configuration format for forward compatibility. See [#119](https://github.com/Azure/apiops-cli/issues/119) for tracking.
144-
145143
The configuration format supports specifying which workspace-scoped resources to extract:
146144

147145
```yaml

src/services/workspace-extractor.ts

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import { IApimClient } from '../clients/iapim-client.js';
99
import { IArtifactStore } from '../clients/iartifact-store.js';
1010
import { ApimServiceContext } from '../models/types.js';
1111
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
12-
import { FilterConfig } from '../models/config.js';
12+
import { FilterConfig, WorkspaceSubFilter } from '../models/config.js';
1313
import { extractResourceType, ExtractedResource } from './resource-extractor.js';
1414
import { extractApiResources, extractWorkspaceApiTags } from './api-extractor.js';
1515
import { extractProductResources, extractWorkspaceProductTags } from './product-extractor.js';
1616
import { logger } from '../lib/logger.js';
1717
import { getNamePart } from '../lib/resource-path.js';
18+
import { isWildcardPattern, wildcardMatch } from './filter-service.js';
1819

1920
/**
2021
* Types that can exist at the workspace level, derived from RESOURCE_TYPE_METADATA.
@@ -58,17 +59,24 @@ export async function extractWorkspaces(
5859
logger.debug('Workspace filter is empty array — excluding all workspaces');
5960
return results;
6061
}
61-
workspaceNames = filter.workspaces;
62+
63+
const hasWildcards = filter.workspaces.some(isWildcardPattern);
64+
if (hasWildcards) {
65+
// Wildcard patterns require discovery so we can match against real names
66+
const discovered = await discoverWorkspaceNames(client, context);
67+
workspaceNames = discovered.filter((name) =>
68+
filter.workspaces!.some((pattern) =>
69+
isWildcardPattern(pattern)
70+
? wildcardMatch(pattern, name)
71+
: pattern.toLowerCase() === name.toLowerCase()
72+
)
73+
);
74+
} else {
75+
workspaceNames = filter.workspaces;
76+
}
6277
} else {
6378
// No workspace filter defined — discover all workspaces
64-
const discovered: string[] = [];
65-
for await (const item of client.listResources(context, ResourceType.Workspace)) {
66-
const name = item['name'];
67-
if (typeof name === 'string') {
68-
discovered.push(name);
69-
}
70-
}
71-
workspaceNames = discovered;
79+
workspaceNames = await discoverWorkspaceNames(client, context);
7280
}
7381

7482
if (workspaceNames.length === 0) {
@@ -92,7 +100,8 @@ export async function extractWorkspaces(
92100
}
93101

94102
const wsResult = await extractWorkspace(
95-
client, store, context, wsName, outputDir, filter
103+
client, store, context, wsName, outputDir,
104+
resolveWorkspaceFilter(wsName, filter)
96105
);
97106
results.push(wsResult);
98107
}
@@ -218,3 +227,68 @@ async function extractWorkspace(
218227

219228
return { workspaceName, resourceCount, errorCount };
220229
}
230+
231+
/**
232+
* Discover all workspace names from APIM.
233+
*/
234+
async function discoverWorkspaceNames(
235+
client: IApimClient,
236+
context: ApimServiceContext
237+
): Promise<string[]> {
238+
const names: string[] = [];
239+
for await (const item of client.listResources(context, ResourceType.Workspace)) {
240+
const name = item['name'];
241+
if (typeof name === 'string') {
242+
names.push(name);
243+
}
244+
}
245+
return names;
246+
}
247+
248+
/**
249+
* Resolve the effective FilterConfig for a workspace.
250+
* If the workspace has a sub-filter in workspaceSubFilters, convert it to a FilterConfig.
251+
* Otherwise return undefined (no filter = extract everything in the workspace).
252+
*/
253+
export function resolveWorkspaceFilter(
254+
workspaceName: string,
255+
filter?: FilterConfig
256+
): FilterConfig | undefined {
257+
if (!filter?.workspaceSubFilters) {
258+
return undefined;
259+
}
260+
261+
// Case-insensitive lookup of workspace sub-filter
262+
const lowerName = workspaceName.toLowerCase();
263+
const matchingKey = Object.keys(filter.workspaceSubFilters).find(
264+
(k) => k.toLowerCase() === lowerName
265+
);
266+
267+
if (!matchingKey) {
268+
return undefined;
269+
}
270+
271+
const sub = filter.workspaceSubFilters[matchingKey];
272+
return workspaceSubFilterToFilterConfig(sub);
273+
}
274+
275+
/**
276+
* Convert a WorkspaceSubFilter to a FilterConfig so the standard
277+
* filter-service matching logic can be applied to workspace-scoped resources.
278+
*/
279+
function workspaceSubFilterToFilterConfig(sub: WorkspaceSubFilter): FilterConfig {
280+
return {
281+
apis: sub.apis,
282+
apiSubFilters: sub.apiSubFilters,
283+
backends: sub.backends,
284+
diagnostics: sub.diagnostics,
285+
groups: sub.groups,
286+
loggers: sub.loggers,
287+
namedValues: sub.namedValues,
288+
policyFragments: sub.policyFragments,
289+
products: sub.products,
290+
subscriptions: sub.subscriptions,
291+
tags: sub.tags,
292+
versionSets: sub.versionSets,
293+
};
294+
}

tests/unit/services/workspace-extractor.test.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ResourceType } from '../../../src/models/resource-types.js';
99
import { ApimServiceContext } from '../../../src/models/types.js';
1010
import { FilterConfig } from '../../../src/models/config.js';
1111
import { extractWorkspaces } from '../../../src/services/workspace-extractor.js';
12+
import { resolveWorkspaceFilter } from '../../../src/services/workspace-extractor.js';
1213

1314
const testContext: ApimServiceContext = {
1415
subscriptionId: 'sub-1',
@@ -202,5 +203,188 @@ describe('workspace-extractor', () => {
202203
expect(results).toHaveLength(1);
203204
expect(results[0]?.resourceCount).toBeGreaterThan(0);
204205
});
206+
207+
it('should apply workspace sub-filter to limit extracted resources', async () => {
208+
const client = createMockClient();
209+
const listedTypes: ResourceType[] = [];
210+
client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType) {
211+
listedTypes.push(type);
212+
if (type === ResourceType.NamedValue) {
213+
yield { name: 'ws-nv-1', properties: {} };
214+
yield { name: 'ws-nv-2', properties: {} };
215+
}
216+
};
217+
const store = createMockStore();
218+
const filter: FilterConfig = {
219+
workspaces: ['ws-1'],
220+
workspaceSubFilters: {
221+
'ws-1': {
222+
namedValues: ['ws-nv-1'],
223+
},
224+
},
225+
};
226+
227+
const results = await extractWorkspaces(
228+
client, store, testContext, '/output', filter
229+
);
230+
231+
expect(results).toHaveLength(1);
232+
// Only ws-nv-1 should be extracted, ws-nv-2 should be filtered out
233+
expect(results[0]?.resourceCount).toBe(1);
234+
});
235+
236+
it('should extract everything when workspace has no sub-filter', async () => {
237+
const client = createMockClient();
238+
client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType) {
239+
if (type === ResourceType.NamedValue) {
240+
yield { name: 'ws-nv-1', properties: {} };
241+
yield { name: 'ws-nv-2', properties: {} };
242+
}
243+
};
244+
const store = createMockStore();
245+
const filter: FilterConfig = {
246+
workspaces: ['ws-1'],
247+
// No workspaceSubFilters — extract everything in the workspace
248+
};
249+
250+
const results = await extractWorkspaces(
251+
client, store, testContext, '/output', filter
252+
);
253+
254+
expect(results).toHaveLength(1);
255+
// Both named values should be extracted
256+
expect(results[0]?.resourceCount).toBe(2);
257+
});
258+
259+
it('should support wildcard patterns in workspace names', async () => {
260+
const client = createMockClient();
261+
// Discovery returns three workspaces
262+
const originalListResources = client.listResources;
263+
let firstCall = true;
264+
client.listResources = async function* (ctx: ApimServiceContext, type: ResourceType) {
265+
if (type === ResourceType.Workspace && firstCall) {
266+
firstCall = false;
267+
yield { name: 'team-a-workspace', properties: {} };
268+
yield { name: 'team-b-workspace', properties: {} };
269+
yield { name: 'other-workspace', properties: {} };
270+
return;
271+
}
272+
yield* originalListResources(ctx, type);
273+
};
274+
const store = createMockStore();
275+
const filter: FilterConfig = { workspaces: ['team-*'] };
276+
277+
const results = await extractWorkspaces(
278+
client, store, testContext, '/output', filter
279+
);
280+
281+
// Only team-a-workspace and team-b-workspace should match
282+
expect(results).toHaveLength(2);
283+
expect(results.map((r) => r.workspaceName).sort()).toEqual([
284+
'team-a-workspace',
285+
'team-b-workspace',
286+
]);
287+
});
288+
289+
it('should apply sub-filter with wildcard workspace name patterns', async () => {
290+
const client = createMockClient();
291+
let firstCall = true;
292+
client.listResources = async function* (_ctx: ApimServiceContext, type: ResourceType) {
293+
if (type === ResourceType.Workspace && firstCall) {
294+
firstCall = false;
295+
yield { name: 'team-a', properties: {} };
296+
return;
297+
}
298+
if (type === ResourceType.NamedValue) {
299+
yield { name: 'nv-1', properties: {} };
300+
yield { name: 'nv-2', properties: {} };
301+
}
302+
};
303+
const store = createMockStore();
304+
const filter: FilterConfig = {
305+
workspaces: ['team-*'],
306+
workspaceSubFilters: {
307+
'team-a': {
308+
namedValues: ['nv-1'],
309+
},
310+
},
311+
};
312+
313+
const results = await extractWorkspaces(
314+
client, store, testContext, '/output', filter
315+
);
316+
317+
expect(results).toHaveLength(1);
318+
expect(results[0]?.workspaceName).toBe('team-a');
319+
// Only nv-1 should be extracted due to sub-filter
320+
expect(results[0]?.resourceCount).toBe(1);
321+
});
322+
});
323+
324+
describe('resolveWorkspaceFilter', () => {
325+
it('should return undefined when no filter provided', () => {
326+
expect(resolveWorkspaceFilter('ws-1')).toBeUndefined();
327+
});
328+
329+
it('should return undefined when no workspaceSubFilters', () => {
330+
const filter: FilterConfig = { workspaces: ['ws-1'] };
331+
expect(resolveWorkspaceFilter('ws-1', filter)).toBeUndefined();
332+
});
333+
334+
it('should return undefined when workspace not in sub-filters', () => {
335+
const filter: FilterConfig = {
336+
workspaces: ['ws-1'],
337+
workspaceSubFilters: {
338+
'ws-2': { apis: ['api-1'] },
339+
},
340+
};
341+
expect(resolveWorkspaceFilter('ws-1', filter)).toBeUndefined();
342+
});
343+
344+
it('should resolve workspace sub-filter case-insensitively', () => {
345+
const filter: FilterConfig = {
346+
workspaces: ['WS-1'],
347+
workspaceSubFilters: {
348+
'ws-1': { apis: ['api-1'], backends: ['backend-1'] },
349+
},
350+
};
351+
const result = resolveWorkspaceFilter('WS-1', filter);
352+
expect(result).toBeDefined();
353+
expect(result!.apis).toEqual(['api-1']);
354+
expect(result!.backends).toEqual(['backend-1']);
355+
});
356+
357+
it('should convert all workspace sub-filter fields to FilterConfig', () => {
358+
const filter: FilterConfig = {
359+
workspaceSubFilters: {
360+
'ws-1': {
361+
apis: ['api-1'],
362+
backends: ['be-1'],
363+
diagnostics: ['diag-1'],
364+
groups: ['group-1'],
365+
loggers: ['logger-1'],
366+
namedValues: ['nv-1'],
367+
policyFragments: ['pf-1'],
368+
products: ['prod-1'],
369+
subscriptions: ['sub-1'],
370+
tags: ['tag-1'],
371+
versionSets: ['vs-1'],
372+
},
373+
},
374+
};
375+
const result = resolveWorkspaceFilter('ws-1', filter);
376+
expect(result).toBeDefined();
377+
expect(result!.apis).toEqual(['api-1']);
378+
expect(result!.backends).toEqual(['be-1']);
379+
expect(result!.diagnostics).toEqual(['diag-1']);
380+
expect(result!.groups).toEqual(['group-1']);
381+
expect(result!.loggers).toEqual(['logger-1']);
382+
expect(result!.namedValues).toEqual(['nv-1']);
383+
expect(result!.policyFragments).toEqual(['pf-1']);
384+
expect(result!.products).toEqual(['prod-1']);
385+
expect(result!.subscriptions).toEqual(['sub-1']);
386+
expect(result!.tags).toEqual(['tag-1']);
387+
expect(result!.versionSets).toEqual(['vs-1']);
388+
});
205389
});
206390
});

0 commit comments

Comments
 (0)