Skip to content

Commit 2e3f0dc

Browse files
authored
Merge pull request #160 from Azure/feat/wildcard-filter-matching
Add wildcard filter matching and workspace sub-resource filtering
2 parents 00aacad + 365bfc4 commit 2e3f0dc

10 files changed

Lines changed: 647 additions & 18 deletions

File tree

docs/commands/extract.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,19 @@ For local development, `az login` is the simplest option. For CI/CD pipelines, u
9595

9696
By default, `apiops extract` exports **all** resources from the APIM instance (34 resource types including APIs, products, backends, named values, tags, policies, and more).
9797

98-
To extract only specific resources, pass a YAML filter file with `--filter`:
98+
To extract only specific resources, pass a YAML filter file with `--filter`. Filter entries support exact names and wildcard patterns (`*` for any characters, `?` for a single character):
9999

100100
```yaml
101101
# configuration.extractor.yaml
102102
apis:
103103
- echo-api
104104
- petstore-api
105+
- 'prod-*' # Wildcard: all APIs starting with prod-
105106
products:
106107
- starter
107108
backends:
108109
- backend-api
110+
- '*-internal' # Wildcard: all backends ending with -internal
109111
namedValues:
110112
- api-key
111113
tags:

docs/getting-started.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Extract only the APIs you care about. Create a `filter.yaml`:
6161
apiNames:
6262
- pet-store-api
6363
- user-api
64+
- 'staging-*' # Wildcard: all APIs starting with staging-
6465
```
6566
6667
```bash

docs/guides/filtering-resources.md

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,47 @@ namedValues:
7272
- Simple fields must be an array of strings
7373
- `apis` and `workspaces` also accept nested object entries for sub-resource filtering (see below)
7474
- Names are matched case-insensitively against APIM resource names
75+
- Wildcard patterns are supported — `*` matches any characters, `?` matches a single character (see below)
76+
- Exact names and wildcard patterns can be mixed in the same array
7577
- An empty file extracts everything (same as no filter)
7678
- An empty array (`[]`) excludes ALL resources of that type
7779

7880
---
7981

82+
## Wildcard Pattern Matching
83+
84+
Filter entries support glob-style wildcard patterns for matching multiple resources by naming convention:
85+
86+
| Wildcard | Meaning | Example |
87+
|----------|---------|---------|
88+
| `*` | Matches zero or more characters | `prod-*` matches `prod-api`, `prod-users` |
89+
| `?` | Matches exactly one character | `api-v?` matches `api-v1`, `api-v2` but not `api-v10` |
90+
91+
### Examples
92+
93+
```yaml
94+
apis:
95+
- '*-test' # All APIs ending with -test
96+
- 'prod-*' # All APIs starting with prod-
97+
- '*-internal-*' # All APIs containing -internal-
98+
- 'v2-*-api' # APIs following v2-{name}-api pattern
99+
- 'echo-api' # Exact names and patterns can be mixed
100+
101+
products:
102+
- 'test-*' # All test products
103+
- '*-starter' # All starter tier products
104+
105+
backends:
106+
- 'backend-*-prod' # All production backends
107+
108+
namedValues:
109+
- '*-connection-string' # All connection string named values
110+
```
111+
112+
Wildcard matching is case-insensitive, just like exact matching. Special characters in resource names (e.g., dots in `my.api.v1`) are treated literally — `my.api.*` matches `my.api.test` but not `myXapiXtest`.
113+
114+
---
115+
80116
## Nested Sub-Resource Filtering
81117

82118
### API sub-resource filters
@@ -104,8 +140,6 @@ apis:
104140

105141
### Workspace sub-resource filters
106142

107-
> **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.
108-
109143
The configuration format supports specifying which workspace-scoped resources to extract:
110144

111145
```yaml
@@ -121,7 +155,7 @@ workspaces:
121155
- team-b-workspace # Simple: extract all resources
122156
```
123157

124-
Supported workspace sub-filter keys: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `subscriptions`, `tags`, `versionSets`.
158+
Supported workspace sub-filter keys: `apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, `policyFragments`, `products`, `schemas`, `subscriptions`, `tags`, `versionSets`.
125159

126160
---
127161

@@ -268,6 +302,20 @@ backends:
268302

269303
There is no "exclude" syntax. To extract everything except certain resources, list all the resources you _do_ want. For large instances, it's often easier to extract everything and use `.gitignore` or separate branches to manage visibility.
270304

305+
### Pattern-Based Team Filtering
306+
307+
Using wildcard patterns to extract resources by naming convention:
308+
309+
```yaml
310+
# configuration.extractor.yaml
311+
apis:
312+
- 'team-payments-*' # All APIs owned by the payments team
313+
namedValues:
314+
- 'payments-*' # All named values for payments
315+
backends:
316+
- '*-payments-*' # All backends related to payments
317+
```
318+
271319
---
272320

273321
## Tips
@@ -276,6 +324,7 @@ There is no "exclude" syntax. To extract everything except certain resources, li
276324
- **One filter per team** — In multi-team setups, each team maintains its own `configuration.extractor.yaml`
277325
- **Commit the filter file** — Keep it in version control alongside your artifacts so CI/CD pipelines can use it
278326
- **Case-insensitive matching** — Filter values are matched case-insensitively against APIM resource names
327+
- **Use wildcard patterns** — `*` and `?` patterns let you match resources by naming convention instead of listing each name individually
279328
- **Validate early** — The config loader validates filter entries and will throw `Failed to load filter config` on invalid YAML. Unknown top-level keys produce a warning.
280329

281330
---

src/lib/config-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export async function loadFilterConfig(filePath: string): Promise<FilterConfig |
186186
if (Object.keys(subFilters).length > 0) {
187187
config.workspaceSubFilters = {};
188188
const validWsSubKeys = ['apis', 'backends', 'diagnostics', 'groups', 'loggers',
189-
'namedValues', 'policyFragments', 'products', 'subscriptions', 'tags', 'versionSets'] as const;
189+
'namedValues', 'policyFragments', 'products', 'schemas', 'subscriptions', 'tags', 'versionSets'] as const;
190190
for (const [wsName, sf] of Object.entries(subFilters)) {
191191
const wsSub: WorkspaceSubFilter = {};
192192
for (const wsField of validWsSubKeys) {

src/models/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface WorkspaceSubFilter {
4444
namedValues?: string[];
4545
policyFragments?: string[];
4646
products?: string[];
47+
schemas?: string[];
4748
subscriptions?: string[];
4849
tags?: string[];
4950
versionSets?: string[];

src/services/filter-service.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,53 @@ export function extractRootApiName(name: string): string {
199199
return semiIndex >= 0 ? name.substring(0, semiIndex) : name;
200200
}
201201

202+
/**
203+
* Check if a pattern contains wildcard characters (* or ?).
204+
*/
205+
export function isWildcardPattern(pattern: string): boolean {
206+
return pattern.includes('*') || pattern.includes('?');
207+
}
208+
209+
/**
210+
* Convert a glob-style wildcard pattern to a RegExp.
211+
* Supports:
212+
* - `*` matches zero or more characters
213+
* - `?` matches exactly one character
214+
* All other characters are escaped for literal matching.
215+
* Matching is case-insensitive.
216+
*/
217+
export function wildcardToRegex(pattern: string): RegExp {
218+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
219+
const regexStr = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
220+
return new RegExp(`^${regexStr}$`, 'i');
221+
}
222+
223+
/** Warn once per pattern that looks likely to cause slow matching. */
224+
const warnedPatterns = new Set<string>();
225+
226+
/**
227+
* Match a string against a glob-style wildcard pattern (case-insensitive).
228+
* Logs a warning for patterns with many wildcards that may be slow.
229+
*/
230+
export function wildcardMatch(pattern: string, text: string): boolean {
231+
const starCount = (pattern.match(/\*/g) ?? []).length;
232+
if (starCount > 4 && !warnedPatterns.has(pattern)) {
233+
warnedPatterns.add(pattern);
234+
logger.warn(
235+
`Filter pattern "${pattern}" has ${starCount} wildcards and may be slow to evaluate`
236+
);
237+
}
238+
return wildcardToRegex(pattern).test(text);
239+
}
240+
202241
/**
203242
* Match a resource name against a filter allowlist.
204243
*
205244
* - undefined allowlist → include all (no filter for this type)
206245
* - empty array → include none
207-
* - non-empty array → case-insensitive match
246+
* - non-empty array → case-insensitive exact match or wildcard pattern match
247+
*
248+
* Wildcard patterns use `*` (zero or more characters) and `?` (single character).
208249
*/
209250
function matchesFilter(name: string, allowlist: string[] | undefined): boolean {
210251
if (allowlist === undefined) {
@@ -220,6 +261,9 @@ function matchesFilter(name: string, allowlist: string[] | undefined): boolean {
220261
const lowerRoot = extractRootApiName(lowerName);
221262

222263
return allowlist.some((allowed) => {
264+
if (isWildcardPattern(allowed)) {
265+
return wildcardMatch(allowed, lowerName) || wildcardMatch(allowed, lowerRoot);
266+
}
223267
const lowerAllowed = allowed.toLowerCase();
224268
return lowerName === lowerAllowed || lowerRoot === lowerAllowed;
225269
});

src/services/workspace-extractor.ts

Lines changed: 96 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,34 @@ export async function extractWorkspaces(
5859
logger.debug('Workspace filter is empty array — excluding all workspaces');
5960
return results;
6061
}
61-
workspaceNames = filter.workspaces;
62-
} else {
63-
// 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);
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+
75+
// Warn about exact (non-wildcard) entries that didn't match any discovered workspace
76+
for (const entry of filter.workspaces) {
77+
if (!isWildcardPattern(entry)) {
78+
const matched = discovered.some((d) => d.toLowerCase() === entry.toLowerCase());
79+
if (!matched) {
80+
logger.warn(`Workspace filter entry "${entry}" did not match any discovered workspace`);
81+
}
82+
}
6983
}
84+
} else {
85+
workspaceNames = filter.workspaces;
7086
}
71-
workspaceNames = discovered;
87+
} else {
88+
// No workspace filter defined — discover all workspaces
89+
workspaceNames = await discoverWorkspaceNames(client, context);
7290
}
7391

7492
if (workspaceNames.length === 0) {
@@ -92,7 +110,8 @@ export async function extractWorkspaces(
92110
}
93111

94112
const wsResult = await extractWorkspace(
95-
client, store, context, wsName, outputDir, filter
113+
client, store, context, wsName, outputDir,
114+
resolveWorkspaceFilter(wsName, filter)
96115
);
97116
results.push(wsResult);
98117
}
@@ -218,3 +237,69 @@ async function extractWorkspace(
218237

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

src/templates/configs/filter-config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ export function generateFilterConfig(): string {
1111
# For full format details and examples, see:
1212
# https://github.com/Azure/apiops-cli/blob/main/docs/guides/filtering-resources.md
1313
14-
# Extract only specific APIs by name
14+
# Extract only specific APIs by name (or wildcard pattern)
1515
# apis:
1616
# - echo-api
1717
# - petstore-api
18+
# - 'prod-*' # Wildcard: all APIs starting with prod-
19+
# - '*-internal-*' # Wildcard: all APIs containing -internal-
1820
1921
# Advanced: Filter API sub-resources (operations, diagnostics, schemas, releases)
2022
# apis:
@@ -23,6 +25,7 @@ export function generateFilterConfig(): string {
2325
# operations:
2426
# - get-pets
2527
# - create-pet
28+
# - 'list-*' # Wildcard: all operations starting with list-
2629
# diagnostics:
2730
# - applicationinsights
2831
# schemas: [] # Exclude all schemas
@@ -116,5 +119,9 @@ export function generateFilterConfig(): string {
116119
# Example:
117120
# gateways: []
118121
# subscriptions: []
122+
# - Use * to match any characters: prod-* matches prod-api, prod-users
123+
# - Use ? to match a single character: api-v? matches api-v1, api-v2
124+
# - Exact names and wildcard patterns can be mixed in the same list
125+
# - All matching is case-insensitive
119126
`;
120127
}

0 commit comments

Comments
 (0)