Skip to content

Commit aa2205d

Browse files
authored
Merge pull request #222 from Azure/petehauge-filter-exclusion-prefix
Support `!`-prefix exclusions in filter entries
2 parents 202eea1 + 42176af commit aa2205d

6 files changed

Lines changed: 240 additions & 9 deletions

File tree

‎docs/commands/extract.md‎

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

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

97-
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):
97+
To extract only specific resources, pass a YAML filter file with `--filter`. Filter entries support exact names, wildcard patterns (`*` for any characters, `?` for a single character), and `!`-prefixed **exclusions** (e.g. `'!prod-legacy'` — see [`filtering-resources.md`](../guides/filtering-resources.md#excluding-resources-with-) for details):
98+
99+
> **Always quote `!`-prefixed entries** — unquoted `- !prod-legacy` is parsed by YAML as a tag and fails to load.
98100
99101
```yaml
100102
# configuration.extractor.yaml
101103
apis:
102104
- echo-api
103105
- petstore-api
104106
- 'prod-*' # Wildcard: all APIs starting with prod-
107+
- '!prod-legacy-*' # Exclusion: skip legacy prod APIs
105108
products:
106109
- starter
107110
backends:

‎docs/guides/filtering-resources.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ namedValues:
8686
- Names are matched case-insensitively against APIM resource names
8787
- Wildcard patterns are supported — `*` matches any characters, `?` matches a single character (see below)
8888
- Exact names and wildcard patterns can be mixed in the same array
89+
- Entries beginning with `!` are **exclusions** — see [Excluding resources with `!`](#excluding-resources-with-) below
8990
- An empty file extracts everything (same as no filter)
9091
- An empty array (`[]`) excludes ALL resources of that type
9192

@@ -125,6 +126,54 @@ Wildcard matching is case-insensitive, just like exact matching. Special charact
125126

126127
---
127128

129+
## Excluding resources with `!`
130+
131+
Any filter entry whose first character is `!` is treated as an **exclusion**. Exclusions are applied *after* inclusions for the same list, so you can write patterns like "include everything matching this shape, except these specific ones."
132+
133+
Semantics:
134+
135+
- `!` must be the **first character** of the entry to count as negation. `foo!bar` is a literal name.
136+
- The rest of the entry is a normal filter value — exact name or wildcard pattern, matched case-insensitively.
137+
- A resource is included iff at least one inclusion matches it **and** no exclusion matches it.
138+
- A list containing only exclusions is treated as "include everything, then subtract" — equivalent to prepending an implicit `*`.
139+
- Exclusions respect the same semantics as inclusions: they match API root names (stripping revision suffixes), and excluding a parent (Api, Product, Gateway, Workspace) cascades to its children.
140+
141+
> **Always quote `!`-prefixed entries in YAML.** An unquoted leading `!` (e.g. `- !prod-legacy-billing`) is parsed by YAML as a **tag** and produces an "unknown tag" error before the filter code ever sees the value. Wrap the entry in single or double quotes, exactly like the examples in this guide: `- '!prod-legacy-billing'`.
142+
143+
### Examples
144+
145+
```yaml
146+
# Include all prod-* APIs except one specific legacy API and any deprecated variants
147+
apis:
148+
- 'prod-*'
149+
- '!prod-legacy-billing'
150+
- '!prod-*-deprecated'
151+
152+
# Include every backend except the shared infra ones
153+
backends:
154+
- '*'
155+
- '!shared-monitoring'
156+
- '!shared-*-infra'
157+
158+
# Include every named value except Key Vault-backed ones (pure-exclusion list)
159+
namedValues:
160+
- '!keyvault-*'
161+
```
162+
163+
Exclusions work anywhere a string list is accepted, including sub-filter fields inside `apiSubFilters` and `workspaceSubFilters`:
164+
165+
```yaml
166+
apis:
167+
- 'my-api'
168+
apiSubFilters:
169+
my-api:
170+
operations:
171+
- 'get-*'
172+
- '!get-internal-*' # keep all get-* operations except internal ones
173+
```
174+
175+
---
176+
128177
## Nested Sub-Resource Filtering
129178

130179
### API sub-resource filters

‎src/services/filter-service.ts‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,19 @@ export function wildcardMatch(pattern: string, text: string): boolean {
238238
return wildcardToRegex(pattern).test(text);
239239
}
240240

241+
/**
242+
* Check whether a single filter entry matches a resource name.
243+
* Handles both exact (case-insensitive) and wildcard matching, and
244+
* matches against the API root name (revision-suffix stripped) as well.
245+
*/
246+
function entryMatches(entry: string, lowerName: string, lowerRoot: string): boolean {
247+
if (isWildcardPattern(entry)) {
248+
return wildcardMatch(entry, lowerName) || wildcardMatch(entry, lowerRoot);
249+
}
250+
const lowerEntry = entry.toLowerCase();
251+
return lowerName === lowerEntry || lowerRoot === lowerEntry;
252+
}
253+
241254
/**
242255
* Match a resource name against a filter allowlist.
243256
*
@@ -246,6 +259,16 @@ export function wildcardMatch(pattern: string, text: string): boolean {
246259
* - non-empty array → case-insensitive exact match or wildcard pattern match
247260
*
248261
* Wildcard patterns use `*` (zero or more characters) and `?` (single character).
262+
*
263+
* Negation: entries beginning with `!` are treated as exclusions. Exclusions
264+
* are evaluated after inclusions for the same list:
265+
* - If the list contains only exclusions, an implicit `*` include is assumed
266+
* ("include everything, then subtract").
267+
* - Otherwise a resource is included iff at least one inclusion matches
268+
* AND no exclusion matches.
269+
* `!` must be the first character to be interpreted as negation; `foo!bar`
270+
* is a literal name. `!` cannot appear in a valid APIM resource name, so a
271+
* leading `!` is unambiguous.
249272
*/
250273
function matchesFilter(name: string, allowlist: string[] | undefined): boolean {
251274
if (allowlist === undefined) {
@@ -256,17 +279,31 @@ function matchesFilter(name: string, allowlist: string[] | undefined): boolean {
256279
return false;
257280
}
258281

282+
const includes: string[] = [];
283+
const excludes: string[] = [];
284+
for (const entry of allowlist) {
285+
if (entry.startsWith('!')) {
286+
excludes.push(entry.slice(1));
287+
} else {
288+
includes.push(entry);
289+
}
290+
}
291+
259292
const lowerName = name.toLowerCase();
260293
// For APIs, also match by root name (strip revision suffix)
261294
const lowerRoot = extractRootApiName(lowerName);
262295

263-
return allowlist.some((allowed) => {
264-
if (isWildcardPattern(allowed)) {
265-
return wildcardMatch(allowed, lowerName) || wildcardMatch(allowed, lowerRoot);
266-
}
267-
const lowerAllowed = allowed.toLowerCase();
268-
return lowerName === lowerAllowed || lowerRoot === lowerAllowed;
269-
});
296+
// Pure-exclusion list is treated as "include-all, then subtract".
297+
const included =
298+
includes.length === 0
299+
? true
300+
: includes.some((entry) => entryMatches(entry, lowerName, lowerRoot));
301+
302+
if (!included) {
303+
return false;
304+
}
305+
306+
return !excludes.some((entry) => entryMatches(entry, lowerName, lowerRoot));
270307
}
271308

272309
/**

‎src/templates/configs/filter-config.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,12 @@
116116
# - Use ? to match a single character: api-v? matches api-v1, api-v2
117117
# - Exact names and wildcard patterns can be mixed in the same list
118118
# - All matching is case-insensitive
119+
# - Prefix an entry with `!` to EXCLUDE it (e.g. '!prod-legacy-*'). A list
120+
# containing only `!` entries means "include everything, then subtract."
121+
# IMPORTANT: always QUOTE `!`-prefixed entries in YAML — an unquoted
122+
# leading `!` is parsed as a YAML tag and fails to load.
123+
# Example:
124+
# apis:
125+
# - 'prod-*'
126+
# - '!prod-legacy-billing'
127+
# - '!prod-*-deprecated'

‎src/templates/copilot/configure-filter-prompt.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Walk through the resource types **one type at a time**. For each type, ask the u
4747

4848
- **Extract ALL** — include every resource of this type. Leave this type **out** of the filter (APIOps extracts everything by default).
4949
- **Extract NONE** — exclude all resources of this type. Add the type with an empty array: `tags: []`.
50-
- **Extract SOME** — include only specific resources. The user provides which names (or wildcard patterns) to include. Matching is case-insensitive and supports `*` and `?` wildcards.
50+
- **Extract SOME** — include only specific resources. The user provides which names (or wildcard patterns) to include. Matching is case-insensitive and supports `*` and `?` wildcards. Entries can also be prefixed with `!` to **exclude** a name or pattern (e.g. `'!prod-legacy-*'`); a list containing only `!` entries means "include everything, then subtract." **Always quote `!`-prefixed entries in YAML** — an unquoted leading `!` is parsed as a YAML tag and fails to load.
5151

5252
**Single-resource-type cadence for Step 1:**
5353

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,4 +583,137 @@ describe('filter-service', () => {
583583
expect(shouldIncludeResource(excluded, filter)).toBe(false);
584584
});
585585
});
586+
587+
describe('negation (`!` prefix) exclusions', () => {
588+
it('should treat a leading `!` as an exclusion in an otherwise-inclusive list', () => {
589+
const filter: FilterConfig = { apis: ['prod-*', '!prod-legacy-billing'] };
590+
const included: ResourceDescriptor = {
591+
type: ResourceType.Api,
592+
nameParts: ['prod-users'],
593+
};
594+
const excluded: ResourceDescriptor = {
595+
type: ResourceType.Api,
596+
nameParts: ['prod-legacy-billing'],
597+
};
598+
expect(shouldIncludeResource(included, filter)).toBe(true);
599+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
600+
});
601+
602+
it('should support wildcard exclusions', () => {
603+
const filter: FilterConfig = { apis: ['prod-*', '!prod-*-deprecated'] };
604+
const included: ResourceDescriptor = {
605+
type: ResourceType.Api,
606+
nameParts: ['prod-users'],
607+
};
608+
const excluded: ResourceDescriptor = {
609+
type: ResourceType.Api,
610+
nameParts: ['prod-users-deprecated'],
611+
};
612+
expect(shouldIncludeResource(included, filter)).toBe(true);
613+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
614+
});
615+
616+
it('should treat a pure-exclusion list as "include all, then subtract"', () => {
617+
const filter: FilterConfig = { apis: ['!prod-legacy-billing'] };
618+
const kept: ResourceDescriptor = {
619+
type: ResourceType.Api,
620+
nameParts: ['prod-users'],
621+
};
622+
const dropped: ResourceDescriptor = {
623+
type: ResourceType.Api,
624+
nameParts: ['prod-legacy-billing'],
625+
};
626+
expect(shouldIncludeResource(kept, filter)).toBe(true);
627+
expect(shouldIncludeResource(dropped, filter)).toBe(false);
628+
});
629+
630+
it('should treat a list with only wildcard exclusions as include-all-minus', () => {
631+
const filter: FilterConfig = { namedValues: ['!keyvault-*'] };
632+
const kept: ResourceDescriptor = {
633+
type: ResourceType.NamedValue,
634+
nameParts: ['api-token'],
635+
};
636+
const dropped: ResourceDescriptor = {
637+
type: ResourceType.NamedValue,
638+
nameParts: ['keyvault-secret'],
639+
};
640+
expect(shouldIncludeResource(kept, filter)).toBe(true);
641+
expect(shouldIncludeResource(dropped, filter)).toBe(false);
642+
});
643+
644+
it('should match exclusions case-insensitively', () => {
645+
const filter: FilterConfig = { apis: ['*', '!Prod-Legacy-Billing'] };
646+
const excluded: ResourceDescriptor = {
647+
type: ResourceType.Api,
648+
nameParts: ['prod-legacy-billing'],
649+
};
650+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
651+
});
652+
653+
it('should exclude API revisions when the root name matches an exclusion', () => {
654+
const filter: FilterConfig = { apis: ['prod-*', '!prod-legacy-billing'] };
655+
const revision: ResourceDescriptor = {
656+
type: ResourceType.Api,
657+
nameParts: ['prod-legacy-billing;rev=2'],
658+
};
659+
expect(shouldIncludeResource(revision, filter)).toBe(false);
660+
});
661+
662+
it('should cascade parent exclusions to child resources', () => {
663+
const filter: FilterConfig = { apis: ['prod-*', '!prod-legacy-billing'] };
664+
const policyOfExcludedApi: ResourceDescriptor = {
665+
type: ResourceType.ApiPolicy,
666+
nameParts: ['prod-legacy-billing'],
667+
};
668+
const policyOfIncludedApi: ResourceDescriptor = {
669+
type: ResourceType.ApiPolicy,
670+
nameParts: ['prod-users'],
671+
};
672+
const opOfExcludedApi: ResourceDescriptor = {
673+
type: ResourceType.ApiOperation,
674+
nameParts: ['prod-legacy-billing', 'get-invoices'],
675+
};
676+
expect(shouldIncludeResource(policyOfExcludedApi, filter)).toBe(false);
677+
expect(shouldIncludeResource(policyOfIncludedApi, filter)).toBe(true);
678+
expect(shouldIncludeResource(opOfExcludedApi, filter)).toBe(false);
679+
});
680+
681+
it('should treat `!` only as negation when it is the first character', () => {
682+
// "foo!bar" is a literal name, not an exclusion of "bar" that starts with "foo".
683+
const filter: FilterConfig = { apis: ['foo!bar'] };
684+
const literal: ResourceDescriptor = {
685+
type: ResourceType.Api,
686+
nameParts: ['foo!bar'],
687+
};
688+
const other: ResourceDescriptor = {
689+
type: ResourceType.Api,
690+
nameParts: ['bar'],
691+
};
692+
// APIM resource names can't actually contain `!`, but the matcher must
693+
// still treat non-leading `!` as a literal character rather than negation.
694+
expect(shouldIncludeResource(literal, filter)).toBe(true);
695+
expect(shouldIncludeResource(other, filter)).toBe(false);
696+
});
697+
698+
it('should apply negation inside apiSubFilters', () => {
699+
const filter: FilterConfig = {
700+
apis: ['my-api'],
701+
apiSubFilters: {
702+
'my-api': {
703+
operations: ['get-*', '!get-internal-*'],
704+
},
705+
},
706+
};
707+
const included: ResourceDescriptor = {
708+
type: ResourceType.ApiOperation,
709+
nameParts: ['my-api', 'get-users'],
710+
};
711+
const excluded: ResourceDescriptor = {
712+
type: ResourceType.ApiOperation,
713+
nameParts: ['my-api', 'get-internal-metrics'],
714+
};
715+
expect(shouldIncludeResource(included, filter)).toBe(true);
716+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
717+
});
718+
});
586719
});

0 commit comments

Comments
 (0)