Skip to content

Commit 138fa32

Browse files
Peter HaugeCopilot
andcommitted
feat: add wildcard/pattern matching support to filter configuration
Add glob-style wildcard support (* and ?) to all name-based filter fields. Patterns are matched case-insensitively using regex with proper escaping of all special characters. A warning is logged for patterns with many wildcards that may be slow to evaluate. Closes #48 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 00aacad commit 138fa32

2 files changed

Lines changed: 303 additions & 1 deletion

File tree

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
});

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

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
shouldIncludeResource,
1313
filterResources,
1414
extractRootApiName,
15+
isWildcardPattern,
16+
wildcardToRegex,
17+
wildcardMatch,
1518
} from '../../../src/services/filter-service.js';
1619

1720
describe('filter-service', () => {
@@ -325,4 +328,259 @@ describe('filter-service', () => {
325328
expect(extractRootApiName('')).toBe('');
326329
});
327330
});
331+
332+
describe('isWildcardPattern', () => {
333+
it('should detect * wildcard', () => {
334+
expect(isWildcardPattern('*-test')).toBe(true);
335+
expect(isWildcardPattern('prod-*')).toBe(true);
336+
expect(isWildcardPattern('*')).toBe(true);
337+
});
338+
339+
it('should detect ? wildcard', () => {
340+
expect(isWildcardPattern('api-v?')).toBe(true);
341+
expect(isWildcardPattern('?-test')).toBe(true);
342+
});
343+
344+
it('should return false for exact names', () => {
345+
expect(isWildcardPattern('my-api')).toBe(false);
346+
expect(isWildcardPattern('prod-api-v2')).toBe(false);
347+
});
348+
});
349+
350+
describe('wildcardMatch', () => {
351+
it('should match * against any characters', () => {
352+
expect(wildcardMatch('prod-*', 'prod-api')).toBe(true);
353+
expect(wildcardMatch('prod-*', 'prod-')).toBe(true);
354+
expect(wildcardMatch('prod-*', 'dev-api')).toBe(false);
355+
});
356+
357+
it('should match ? against a single character', () => {
358+
expect(wildcardMatch('api-v?', 'api-v1')).toBe(true);
359+
expect(wildcardMatch('api-v?', 'api-v2')).toBe(true);
360+
expect(wildcardMatch('api-v?', 'api-v10')).toBe(false);
361+
expect(wildcardMatch('api-v?', 'api-v')).toBe(false);
362+
});
363+
364+
it('should treat dots in patterns as literal dots, not regex any-char', () => {
365+
// "myapi.*" should match "myapi.test" but NOT "myapixtest"
366+
expect(wildcardMatch('myapi.*', 'myapi.test')).toBe(true);
367+
expect(wildcardMatch('myapi.*', 'myapi.v2')).toBe(true);
368+
expect(wildcardMatch('myapi.*', 'myapixtest')).toBe(false);
369+
expect(wildcardMatch('myapi.*', 'myapi-test')).toBe(false);
370+
});
371+
372+
it('should handle names with dots when matching exact segments', () => {
373+
// "*.test" should match "echo.test" but not "echo-test"
374+
expect(wildcardMatch('*.test', 'echo.test')).toBe(true);
375+
expect(wildcardMatch('*.test', 'petstore.test')).toBe(true);
376+
expect(wildcardMatch('*.test', 'echo-test')).toBe(false);
377+
expect(wildcardMatch('*.test', 'echoXtest')).toBe(false);
378+
});
379+
380+
it('should handle names with multiple dots', () => {
381+
expect(wildcardMatch('api.v1.*', 'api.v1.test')).toBe(true);
382+
expect(wildcardMatch('api.v1.*', 'api.v1.prod')).toBe(true);
383+
expect(wildcardMatch('api.v1.*', 'api.v2.test')).toBe(false);
384+
expect(wildcardMatch('*.v1.*', 'api.v1.test')).toBe(true);
385+
expect(wildcardMatch('*.v1.*', 'svc.v1.prod')).toBe(true);
386+
});
387+
388+
it('should treat other regex special chars as literals', () => {
389+
// Names with parentheses, brackets, plus, etc.
390+
expect(wildcardMatch('api(v1)*', 'api(v1)-test')).toBe(true);
391+
expect(wildcardMatch('api[1]*', 'api[1]-prod')).toBe(true);
392+
expect(wildcardMatch('api+v1*', 'api+v1-test')).toBe(true);
393+
});
394+
395+
it('should be case-insensitive', () => {
396+
expect(wildcardMatch('Prod-*', 'prod-api')).toBe(true);
397+
expect(wildcardMatch('Prod-*', 'PROD-API')).toBe(true);
398+
});
399+
});
400+
401+
describe('wildcardToRegex', () => {
402+
it('should produce a regex that anchors to start and end', () => {
403+
const regex = wildcardToRegex('prod-*');
404+
expect(regex.test('prod-api')).toBe(true);
405+
expect(regex.test('xxprod-api')).toBe(false);
406+
});
407+
408+
it('should escape regex special characters', () => {
409+
const regex = wildcardToRegex('api.v1.*');
410+
expect(regex.test('api.v1.test')).toBe(true);
411+
expect(regex.test('apixv1xtest')).toBe(false);
412+
});
413+
});
414+
415+
describe('wildcard pattern matching in shouldIncludeResource', () => {
416+
it('should match APIs ending with a suffix using *-suffix pattern', () => {
417+
const filter: FilterConfig = { apis: ['*-test'] };
418+
const included: ResourceDescriptor = {
419+
type: ResourceType.Api,
420+
nameParts: ['echo-test'],
421+
};
422+
const excluded: ResourceDescriptor = {
423+
type: ResourceType.Api,
424+
nameParts: ['echo-prod'],
425+
};
426+
expect(shouldIncludeResource(included, filter)).toBe(true);
427+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
428+
});
429+
430+
it('should match APIs starting with a prefix using prefix-* pattern', () => {
431+
const filter: FilterConfig = { apis: ['prod-*'] };
432+
const included: ResourceDescriptor = {
433+
type: ResourceType.Api,
434+
nameParts: ['prod-users-api'],
435+
};
436+
const excluded: ResourceDescriptor = {
437+
type: ResourceType.Api,
438+
nameParts: ['dev-users-api'],
439+
};
440+
expect(shouldIncludeResource(included, filter)).toBe(true);
441+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
442+
});
443+
444+
it('should match APIs containing a substring using *-substr-* pattern', () => {
445+
const filter: FilterConfig = { apis: ['*-internal-*'] };
446+
const included: ResourceDescriptor = {
447+
type: ResourceType.Api,
448+
nameParts: ['company-internal-users'],
449+
};
450+
const excluded: ResourceDescriptor = {
451+
type: ResourceType.Api,
452+
nameParts: ['company-external-users'],
453+
};
454+
expect(shouldIncludeResource(included, filter)).toBe(true);
455+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
456+
});
457+
458+
it('should match all resources with * wildcard', () => {
459+
const filter: FilterConfig = { apis: ['*'] };
460+
const descriptor: ResourceDescriptor = {
461+
type: ResourceType.Api,
462+
nameParts: ['any-api-name'],
463+
};
464+
expect(shouldIncludeResource(descriptor, filter)).toBe(true);
465+
});
466+
467+
it('should support ? for single character matching', () => {
468+
const filter: FilterConfig = { apis: ['api-v?'] };
469+
const v1: ResourceDescriptor = {
470+
type: ResourceType.Api,
471+
nameParts: ['api-v1'],
472+
};
473+
const v2: ResourceDescriptor = {
474+
type: ResourceType.Api,
475+
nameParts: ['api-v2'],
476+
};
477+
const v10: ResourceDescriptor = {
478+
type: ResourceType.Api,
479+
nameParts: ['api-v10'],
480+
};
481+
expect(shouldIncludeResource(v1, filter)).toBe(true);
482+
expect(shouldIncludeResource(v2, filter)).toBe(true);
483+
expect(shouldIncludeResource(v10, filter)).toBe(false);
484+
});
485+
486+
it('should support mixing exact names and wildcard patterns', () => {
487+
const filter: FilterConfig = { apis: ['echo-api', 'prod-*'] };
488+
const exact: ResourceDescriptor = {
489+
type: ResourceType.Api,
490+
nameParts: ['echo-api'],
491+
};
492+
const pattern: ResourceDescriptor = {
493+
type: ResourceType.Api,
494+
nameParts: ['prod-users'],
495+
};
496+
const neither: ResourceDescriptor = {
497+
type: ResourceType.Api,
498+
nameParts: ['dev-users'],
499+
};
500+
expect(shouldIncludeResource(exact, filter)).toBe(true);
501+
expect(shouldIncludeResource(pattern, filter)).toBe(true);
502+
expect(shouldIncludeResource(neither, filter)).toBe(false);
503+
});
504+
505+
it('should apply wildcard matching case-insensitively', () => {
506+
const filter: FilterConfig = { apis: ['Prod-*'] };
507+
const descriptor: ResourceDescriptor = {
508+
type: ResourceType.Api,
509+
nameParts: ['PROD-Users-Api'],
510+
};
511+
expect(shouldIncludeResource(descriptor, filter)).toBe(true);
512+
});
513+
514+
it('should apply wildcard matching to non-API resource types', () => {
515+
const filter: FilterConfig = {
516+
backends: ['backend-*-prod'],
517+
products: ['test-*'],
518+
namedValues: ['*-secret'],
519+
};
520+
const backend: ResourceDescriptor = {
521+
type: ResourceType.Backend,
522+
nameParts: ['backend-users-prod'],
523+
};
524+
const product: ResourceDescriptor = {
525+
type: ResourceType.Product,
526+
nameParts: ['test-starter'],
527+
};
528+
const namedValue: ResourceDescriptor = {
529+
type: ResourceType.NamedValue,
530+
nameParts: ['db-connection-secret'],
531+
};
532+
const excludedBackend: ResourceDescriptor = {
533+
type: ResourceType.Backend,
534+
nameParts: ['backend-users-dev'],
535+
};
536+
expect(shouldIncludeResource(backend, filter)).toBe(true);
537+
expect(shouldIncludeResource(product, filter)).toBe(true);
538+
expect(shouldIncludeResource(namedValue, filter)).toBe(true);
539+
expect(shouldIncludeResource(excludedBackend, filter)).toBe(false);
540+
});
541+
542+
it('should apply wildcard matching to API revisions by root name', () => {
543+
const filter: FilterConfig = { apis: ['prod-*'] };
544+
const descriptor: ResourceDescriptor = {
545+
type: ResourceType.Api,
546+
nameParts: ['prod-users;rev=3'],
547+
};
548+
expect(shouldIncludeResource(descriptor, filter)).toBe(true);
549+
});
550+
551+
it('should apply wildcard matching to child resources via parent name', () => {
552+
const filter: FilterConfig = { apis: ['prod-*'] };
553+
const apiPolicy: ResourceDescriptor = {
554+
type: ResourceType.ApiPolicy,
555+
nameParts: ['prod-users'],
556+
};
557+
const excludedPolicy: ResourceDescriptor = {
558+
type: ResourceType.ApiPolicy,
559+
nameParts: ['dev-users'],
560+
};
561+
expect(shouldIncludeResource(apiPolicy, filter)).toBe(true);
562+
expect(shouldIncludeResource(excludedPolicy, filter)).toBe(false);
563+
});
564+
565+
it('should apply wildcard matching in apiSubFilters operations', () => {
566+
const filter: FilterConfig = {
567+
apis: ['my-api'],
568+
apiSubFilters: {
569+
'my-api': {
570+
operations: ['get-*'],
571+
},
572+
},
573+
};
574+
const included: ResourceDescriptor = {
575+
type: ResourceType.ApiOperation,
576+
nameParts: ['my-api', 'get-users'],
577+
};
578+
const excluded: ResourceDescriptor = {
579+
type: ResourceType.ApiOperation,
580+
nameParts: ['my-api', 'post-users'],
581+
};
582+
expect(shouldIncludeResource(included, filter)).toBe(true);
583+
expect(shouldIncludeResource(excluded, filter)).toBe(false);
584+
});
585+
});
328586
});

0 commit comments

Comments
 (0)