feat(routing): Implement unified routing and matching system

- Introduced a centralized routing module with comprehensive matchers for domains, headers, IPs, and paths.
- Added DomainMatcher for domain pattern matching with support for wildcards and specificity calculation.
- Implemented HeaderMatcher for HTTP header matching, including exact matches and pattern support.
- Developed IpMatcher for IP address matching, supporting CIDR notation, ranges, and wildcards.
- Created PathMatcher for path matching with parameter extraction and wildcard support.
- Established RouteSpecificity class to calculate and compare route specificity scores.
- Enhanced HttpRouter to utilize the new matching system, supporting both modern and legacy route configurations.
- Added detailed logging and error handling for routing operations.
This commit is contained in:
2025-06-02 03:57:52 +00:00
parent 01e1153fb8
commit 54ffbadb86
28 changed files with 1827 additions and 1724 deletions

View File

@ -0,0 +1,79 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DomainMatcher } from '../../../ts/core/routing/matchers/domain.js';
tap.test('DomainMatcher - exact match', async () => {
expect(DomainMatcher.match('example.com', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.com', 'example.net')).toEqual(false);
expect(DomainMatcher.match('sub.example.com', 'example.com')).toEqual(false);
});
tap.test('DomainMatcher - case insensitive', async () => {
expect(DomainMatcher.match('Example.COM', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.com', 'EXAMPLE.COM')).toEqual(true);
expect(DomainMatcher.match('ExAmPlE.cOm', 'eXaMpLe.CoM')).toEqual(true);
});
tap.test('DomainMatcher - wildcard matching', async () => {
// Leading wildcard
expect(DomainMatcher.match('*.example.com', 'sub.example.com')).toEqual(true);
expect(DomainMatcher.match('*.example.com', 'deep.sub.example.com')).toEqual(true);
expect(DomainMatcher.match('*.example.com', 'example.com')).toEqual(false);
// Multiple wildcards
expect(DomainMatcher.match('*.*.example.com', 'a.b.example.com')).toEqual(true);
expect(DomainMatcher.match('api.*.example.com', 'api.v1.example.com')).toEqual(true);
// Trailing wildcard
expect(DomainMatcher.match('example.*', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.*', 'example.net')).toEqual(true);
expect(DomainMatcher.match('example.*', 'example.co.uk')).toEqual(true);
});
tap.test('DomainMatcher - FQDN normalization', async () => {
expect(DomainMatcher.match('example.com.', 'example.com')).toEqual(true);
expect(DomainMatcher.match('example.com', 'example.com.')).toEqual(true);
expect(DomainMatcher.match('example.com.', 'example.com.')).toEqual(true);
});
tap.test('DomainMatcher - edge cases', async () => {
expect(DomainMatcher.match('', 'example.com')).toEqual(false);
expect(DomainMatcher.match('example.com', '')).toEqual(false);
expect(DomainMatcher.match('', '')).toEqual(false);
expect(DomainMatcher.match(null as any, 'example.com')).toEqual(false);
expect(DomainMatcher.match('example.com', null as any)).toEqual(false);
});
tap.test('DomainMatcher - specificity calculation', async () => {
// Exact domains are most specific
const exactScore = DomainMatcher.calculateSpecificity('api.example.com');
const wildcardScore = DomainMatcher.calculateSpecificity('*.example.com');
const leadingWildcardScore = DomainMatcher.calculateSpecificity('*.com');
expect(exactScore).toBeGreaterThan(wildcardScore);
expect(wildcardScore).toBeGreaterThan(leadingWildcardScore);
// More segments = more specific
const threeSegments = DomainMatcher.calculateSpecificity('api.v1.example.com');
const twoSegments = DomainMatcher.calculateSpecificity('example.com');
expect(threeSegments).toBeGreaterThan(twoSegments);
});
tap.test('DomainMatcher - findAllMatches', async () => {
const patterns = [
'example.com',
'*.example.com',
'api.example.com',
'*.api.example.com',
'*'
];
const matches = DomainMatcher.findAllMatches(patterns, 'v1.api.example.com');
// Should match: *.example.com, *.api.example.com, *
expect(matches).toHaveLength(3);
expect(matches[0]).toEqual('*.api.example.com'); // Most specific
expect(matches[1]).toEqual('*.example.com');
expect(matches[2]).toEqual('*'); // Least specific
});
tap.start();

View File

@ -0,0 +1,118 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { IpMatcher } from '../../../ts/core/routing/matchers/ip.js';
tap.test('IpMatcher - exact match', async () => {
expect(IpMatcher.match('192.168.1.1', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.1', '192.168.1.2')).toEqual(false);
expect(IpMatcher.match('10.0.0.1', '10.0.0.1')).toEqual(true);
});
tap.test('IpMatcher - CIDR notation', async () => {
// /24 subnet
expect(IpMatcher.match('192.168.1.0/24', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.0/24', '192.168.1.255')).toEqual(true);
expect(IpMatcher.match('192.168.1.0/24', '192.168.2.1')).toEqual(false);
// /16 subnet
expect(IpMatcher.match('10.0.0.0/16', '10.0.1.1')).toEqual(true);
expect(IpMatcher.match('10.0.0.0/16', '10.0.255.255')).toEqual(true);
expect(IpMatcher.match('10.0.0.0/16', '10.1.0.1')).toEqual(false);
// /32 (single host)
expect(IpMatcher.match('192.168.1.1/32', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.1/32', '192.168.1.2')).toEqual(false);
});
tap.test('IpMatcher - wildcard matching', async () => {
expect(IpMatcher.match('192.168.1.*', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.*', '192.168.1.255')).toEqual(true);
expect(IpMatcher.match('192.168.1.*', '192.168.2.1')).toEqual(false);
expect(IpMatcher.match('192.168.*.*', '192.168.0.1')).toEqual(true);
expect(IpMatcher.match('192.168.*.*', '192.168.255.255')).toEqual(true);
expect(IpMatcher.match('192.168.*.*', '192.169.0.1')).toEqual(false);
expect(IpMatcher.match('*.*.*.*', '1.2.3.4')).toEqual(true);
expect(IpMatcher.match('*.*.*.*', '255.255.255.255')).toEqual(true);
});
tap.test('IpMatcher - range matching', async () => {
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.5')).toEqual(true);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.10')).toEqual(true);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.11')).toEqual(false);
expect(IpMatcher.match('192.168.1.1-192.168.1.10', '192.168.1.0')).toEqual(false);
});
tap.test('IpMatcher - IPv6-mapped IPv4', async () => {
expect(IpMatcher.match('192.168.1.1', '::ffff:192.168.1.1')).toEqual(true);
expect(IpMatcher.match('192.168.1.0/24', '::ffff:192.168.1.100')).toEqual(true);
expect(IpMatcher.match('192.168.1.*', '::FFFF:192.168.1.50')).toEqual(true);
});
tap.test('IpMatcher - IP validation', async () => {
expect(IpMatcher.isValidIpv4('192.168.1.1')).toEqual(true);
expect(IpMatcher.isValidIpv4('255.255.255.255')).toEqual(true);
expect(IpMatcher.isValidIpv4('0.0.0.0')).toEqual(true);
expect(IpMatcher.isValidIpv4('256.1.1.1')).toEqual(false);
expect(IpMatcher.isValidIpv4('1.1.1')).toEqual(false);
expect(IpMatcher.isValidIpv4('1.1.1.1.1')).toEqual(false);
expect(IpMatcher.isValidIpv4('1.1.1.a')).toEqual(false);
expect(IpMatcher.isValidIpv4('01.1.1.1')).toEqual(false); // No leading zeros
});
tap.test('IpMatcher - isAuthorized', async () => {
// Empty lists - allow all
expect(IpMatcher.isAuthorized('192.168.1.1')).toEqual(true);
// Allow list only
const allowList = ['192.168.1.0/24', '10.0.0.0/16'];
expect(IpMatcher.isAuthorized('192.168.1.100', allowList)).toEqual(true);
expect(IpMatcher.isAuthorized('10.0.50.1', allowList)).toEqual(true);
expect(IpMatcher.isAuthorized('172.16.0.1', allowList)).toEqual(false);
// Block list only
const blockList = ['192.168.1.100', '10.0.0.0/24'];
expect(IpMatcher.isAuthorized('192.168.1.100', [], blockList)).toEqual(false);
expect(IpMatcher.isAuthorized('10.0.0.50', [], blockList)).toEqual(false);
expect(IpMatcher.isAuthorized('192.168.1.101', [], blockList)).toEqual(true);
// Both lists - block takes precedence
expect(IpMatcher.isAuthorized('192.168.1.100', allowList, ['192.168.1.100'])).toEqual(false);
});
tap.test('IpMatcher - specificity calculation', async () => {
// Exact IPs are most specific
const exactScore = IpMatcher.calculateSpecificity('192.168.1.1');
const cidr32Score = IpMatcher.calculateSpecificity('192.168.1.1/32');
const cidr24Score = IpMatcher.calculateSpecificity('192.168.1.0/24');
const cidr16Score = IpMatcher.calculateSpecificity('192.168.0.0/16');
const wildcardScore = IpMatcher.calculateSpecificity('192.168.1.*');
const rangeScore = IpMatcher.calculateSpecificity('192.168.1.1-192.168.1.10');
expect(exactScore).toBeGreaterThan(cidr24Score);
expect(cidr32Score).toBeGreaterThan(cidr24Score);
expect(cidr24Score).toBeGreaterThan(cidr16Score);
expect(rangeScore).toBeGreaterThan(wildcardScore);
});
tap.test('IpMatcher - edge cases', async () => {
// Empty/null inputs
expect(IpMatcher.match('', '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.1', '')).toEqual(false);
expect(IpMatcher.match(null as any, '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.1', null as any)).toEqual(false);
// Invalid CIDR
expect(IpMatcher.match('192.168.1.0/33', '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.0/-1', '192.168.1.1')).toEqual(false);
expect(IpMatcher.match('192.168.1.0/', '192.168.1.1')).toEqual(false);
// Invalid ranges
expect(IpMatcher.match('192.168.1.10-192.168.1.1', '192.168.1.5')).toEqual(false); // Start > end
expect(IpMatcher.match('192.168.1.1-', '192.168.1.5')).toEqual(false);
expect(IpMatcher.match('-192.168.1.10', '192.168.1.5')).toEqual(false);
});
tap.start();

View File

@ -0,0 +1,126 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { PathMatcher } from '../../../ts/core/routing/matchers/path.js';
tap.test('PathMatcher - exact match', async () => {
const result = PathMatcher.match('/api/users', '/api/users');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api/users');
expect(result.pathRemainder).toEqual('');
expect(result.params).toEqual({});
});
tap.test('PathMatcher - no match', async () => {
const result = PathMatcher.match('/api/users', '/api/posts');
expect(result.matches).toEqual(false);
});
tap.test('PathMatcher - parameter extraction', async () => {
const result = PathMatcher.match('/users/:id/profile', '/users/123/profile');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ id: '123' });
expect(result.pathMatch).toEqual('/users/123/profile');
expect(result.pathRemainder).toEqual('');
});
tap.test('PathMatcher - multiple parameters', async () => {
const result = PathMatcher.match('/api/:version/users/:id', '/api/v2/users/456');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v2', id: '456' });
});
tap.test('PathMatcher - wildcard matching', async () => {
const result = PathMatcher.match('/api/*', '/api/users/123/profile');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/api'); // Normalized without trailing slash
expect(result.pathRemainder).toEqual('users/123/profile');
});
tap.test('PathMatcher - mixed parameters and wildcards', async () => {
const result = PathMatcher.match('/api/:version/*', '/api/v1/users/123');
expect(result.matches).toEqual(true);
expect(result.params).toEqual({ version: 'v1' });
expect(result.pathRemainder).toEqual('users/123');
});
tap.test('PathMatcher - trailing slash normalization', async () => {
// Both with trailing slash
let result = PathMatcher.match('/api/users/', '/api/users/');
expect(result.matches).toEqual(true);
// Pattern with, path without
result = PathMatcher.match('/api/users/', '/api/users');
expect(result.matches).toEqual(true);
// Pattern without, path with
result = PathMatcher.match('/api/users', '/api/users/');
expect(result.matches).toEqual(true);
});
tap.test('PathMatcher - root path handling', async () => {
const result = PathMatcher.match('/', '/');
expect(result.matches).toEqual(true);
expect(result.pathMatch).toEqual('/');
expect(result.pathRemainder).toEqual('');
});
tap.test('PathMatcher - specificity calculation', async () => {
// Exact paths are most specific
const exactScore = PathMatcher.calculateSpecificity('/api/v1/users');
const paramScore = PathMatcher.calculateSpecificity('/api/:version/users');
const wildcardScore = PathMatcher.calculateSpecificity('/api/*');
expect(exactScore).toBeGreaterThan(paramScore);
expect(paramScore).toBeGreaterThan(wildcardScore);
// More segments = more specific
const deepPath = PathMatcher.calculateSpecificity('/api/v1/users/profile/settings');
const shallowPath = PathMatcher.calculateSpecificity('/api/users');
expect(deepPath).toBeGreaterThan(shallowPath);
// More static segments = more specific
const moreStatic = PathMatcher.calculateSpecificity('/api/v1/users/:id');
const lessStatic = PathMatcher.calculateSpecificity('/api/:version/:resource/:id');
expect(moreStatic).toBeGreaterThan(lessStatic);
});
tap.test('PathMatcher - findAllMatches', async () => {
const patterns = [
'/api/users',
'/api/users/:id',
'/api/users/:id/profile',
'/api/*',
'/*'
];
const matches = PathMatcher.findAllMatches(patterns, '/api/users/123/profile');
// All patterns should match (including /api/users as a prefix match)
expect(matches).toHaveLength(5);
// Verify all expected patterns are in the results
const matchedPatterns = matches.map(m => m.pattern);
expect(matchedPatterns).toContain('/api/users');
expect(matchedPatterns).toContain('/api/users/:id');
expect(matchedPatterns).toContain('/api/users/:id/profile');
expect(matchedPatterns).toContain('/api/*');
expect(matchedPatterns).toContain('/*');
// Verify parameters were extracted correctly for parameterized patterns
const paramsById = matches.find(m => m.pattern === '/api/users/:id');
const paramsByIdProfile = matches.find(m => m.pattern === '/api/users/:id/profile');
expect(paramsById?.result.params).toEqual({ id: '123' });
expect(paramsByIdProfile?.result.params).toEqual({ id: '123' });
});
tap.test('PathMatcher - edge cases', async () => {
// Empty patterns
expect(PathMatcher.match('', '/api/users').matches).toEqual(false);
expect(PathMatcher.match('/api/users', '').matches).toEqual(false);
expect(PathMatcher.match('', '').matches).toEqual(false);
// Null/undefined
expect(PathMatcher.match(null as any, '/api/users').matches).toEqual(false);
expect(PathMatcher.match('/api/users', null as any).matches).toEqual(false);
});
tap.start();