mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-10 07:22:29 -04:00
feat: konnect integration expressions support (#9830)
This commit is contained in:
1 parent
86d9e2ba0c
commit
02aa32f393
4 files changed
+511
-34
No files matched your search
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyExpressionFields, extractFieldsFromExpression } from '../expression-parser';
|
||||
|
||||
describe('extractFieldsFromExpression', () => {
|
||||
it('single method', () => {
|
||||
const result = extractFieldsFromExpression('http.method == "GET"');
|
||||
expect(result.methods).toEqual(['GET']);
|
||||
expect(result.paths).toBeNull();
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('single path (exact)', () => {
|
||||
const result = extractFieldsFromExpression('http.path == "/users"');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toEqual(['/users']);
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('single path (prefix)', () => {
|
||||
const result = extractFieldsFromExpression('http.path ^= "/api"');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toEqual(['/api']);
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('single host', () => {
|
||||
const result = extractFieldsFromExpression('http.host == "api.example.com"');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toBeNull();
|
||||
expect(result.hosts).toEqual(['api.example.com']);
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('single header', () => {
|
||||
const result = extractFieldsFromExpression('http.headers.x_api_version == "2"');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toBeNull();
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toEqual({ 'x-api-version': ['2'] });
|
||||
});
|
||||
|
||||
it('AND combination: method + path', () => {
|
||||
const result = extractFieldsFromExpression('http.method == "GET" && http.path == "/foo"');
|
||||
expect(result.methods).toEqual(['GET']);
|
||||
expect(result.paths).toEqual(['/foo']);
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('full combination: method + path + host + header ANDed', () => {
|
||||
const result = extractFieldsFromExpression(
|
||||
'http.method == "POST" && http.path == "/submit" && http.host == "api.example.com" && http.headers.x_tenant == "acme"',
|
||||
);
|
||||
expect(result.methods).toEqual(['POST']);
|
||||
expect(result.paths).toEqual(['/submit']);
|
||||
expect(result.hosts).toEqual(['api.example.com']);
|
||||
expect(result.headers).toEqual({ 'x-tenant': ['acme'] });
|
||||
});
|
||||
|
||||
it('OR methods', () => {
|
||||
const result = extractFieldsFromExpression('http.method == "GET" || http.method == "POST"');
|
||||
expect(result.methods).toEqual(['GET', 'POST']);
|
||||
expect(result.paths).toBeNull();
|
||||
});
|
||||
|
||||
it('OR paths', () => {
|
||||
const result = extractFieldsFromExpression('http.path == "/v1" || http.path == "/v2"');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toEqual(['/v1', '/v2']);
|
||||
});
|
||||
|
||||
it('mixed AND/OR', () => {
|
||||
const result = extractFieldsFromExpression(
|
||||
'(http.method == "GET" || http.method == "POST") && http.path == "/api"',
|
||||
);
|
||||
expect(result.methods).toEqual(['GET', 'POST']);
|
||||
expect(result.paths).toEqual(['/api']);
|
||||
});
|
||||
|
||||
it('unparseable — all null', () => {
|
||||
const result = extractFieldsFromExpression('net.src.ip in 10.0.0.0/8');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toBeNull();
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('empty string — all null', () => {
|
||||
const result = extractFieldsFromExpression('');
|
||||
expect(result.methods).toBeNull();
|
||||
expect(result.paths).toBeNull();
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('negation ignored — methods null', () => {
|
||||
const result = extractFieldsFromExpression('http.method != "DELETE"');
|
||||
expect(result.methods).toBeNull();
|
||||
});
|
||||
|
||||
it('regex path ignored — paths null', () => {
|
||||
const result = extractFieldsFromExpression('http.path ~ r#"^/users/\\d+$"#');
|
||||
expect(result.paths).toBeNull();
|
||||
});
|
||||
|
||||
it('partial extraction: method extracted, unparseable part ignored', () => {
|
||||
const result = extractFieldsFromExpression('http.method == "GET" && net.src.ip in 10.0.0.0/8');
|
||||
expect(result.methods).toEqual(['GET']);
|
||||
expect(result.paths).toBeNull();
|
||||
expect(result.hosts).toBeNull();
|
||||
expect(result.headers).toBeNull();
|
||||
});
|
||||
|
||||
it('header name normalization: underscores to hyphens, lowercased', () => {
|
||||
const result = extractFieldsFromExpression('http.headers.X_Custom_Id == "123"');
|
||||
expect(result.headers).toEqual({ 'x-custom-id': ['123'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyExpressionFields', () => {
|
||||
const baseRoute = {
|
||||
id: 'r1', name: 'My Route', methods: null, paths: null,
|
||||
protocols: ['http'], hosts: null, headers: null, snis: null, service: null,
|
||||
};
|
||||
|
||||
it('no expression — passthrough', () => {
|
||||
const result = applyExpressionFields({ ...baseRoute, expression: null });
|
||||
expect(result).toEqual({ syncable: true, route: { ...baseRoute, expression: null } });
|
||||
});
|
||||
|
||||
it('tls.sni in expression — skipped', () => {
|
||||
const result = applyExpressionFields({ ...baseRoute, expression: 'tls.sni == "secure.example.com"' });
|
||||
expect(result.syncable).toBe(false);
|
||||
if (!result.syncable) {
|
||||
expect(result.reason).toMatch(/tls\.sni/);
|
||||
}
|
||||
});
|
||||
|
||||
it('tls.sni combined with other predicates — still skipped', () => {
|
||||
const result = applyExpressionFields({ ...baseRoute, expression: 'tls.sni == "secure.example.com" && http.method == "GET"' });
|
||||
expect(result.syncable).toBe(false);
|
||||
});
|
||||
|
||||
it('fully unparseable expression — skipped', () => {
|
||||
const result = applyExpressionFields({ ...baseRoute, expression: 'net.src.ip in 10.0.0.0/8' });
|
||||
expect(result.syncable).toBe(false);
|
||||
if (!result.syncable) {
|
||||
expect(result.reason).toMatch(/no extractable fields/);
|
||||
}
|
||||
});
|
||||
|
||||
it('parseable expression — returns merged route', () => {
|
||||
const result = applyExpressionFields({ ...baseRoute, expression: 'http.method == "GET" && http.path == "/foo"' });
|
||||
expect(result.syncable).toBe(true);
|
||||
if (result.syncable) {
|
||||
expect(result.route.methods).toEqual(['GET']);
|
||||
expect(result.route.paths).toEqual(['/foo']);
|
||||
}
|
||||
});
|
||||
|
||||
it('partial expression — syncable with extracted fields only', () => {
|
||||
const result = applyExpressionFields({ ...baseRoute, expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8' });
|
||||
expect(result.syncable).toBe(true);
|
||||
if (result.syncable) {
|
||||
expect(result.route.methods).toEqual(['GET']);
|
||||
expect(result.route.paths).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initDatabase, models, services as insoservices } from '~/insomnia-data';
|
||||
import { initDatabase, models, type Request,services as insoservices } from '~/insomnia-data';
|
||||
|
||||
import { database as db } from '../../common/database';
|
||||
import { mainDatabase } from '../../main/database.main';
|
||||
@@ -130,10 +130,10 @@ describe('Feature: HTTP Route Sync', () => {
|
||||
// 2 methods × 2 protocols = 4 requests
|
||||
expect(requests).toHaveLength(4);
|
||||
|
||||
const httpGet = requests.find((r: any) => r.method === 'GET' && r.konnectRouteKey.endsWith(':http'));
|
||||
const httpsGet = requests.find((r: any) => r.method === 'GET' && r.konnectRouteKey.endsWith(':https'));
|
||||
const httpPost = requests.find((r: any) => r.method === 'POST' && r.konnectRouteKey.endsWith(':http'));
|
||||
const httpsPost = requests.find((r: any) => r.method === 'POST' && r.konnectRouteKey.endsWith(':https'));
|
||||
const httpGet = requests.find(r => r.method === 'GET' && r.konnectRouteKey?.endsWith(':http'));
|
||||
const httpsGet = requests.find(r => r.method === 'GET' && r.konnectRouteKey?.endsWith(':https'));
|
||||
const httpPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':http'));
|
||||
const httpsPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':https'));
|
||||
|
||||
expect(httpGet).toMatchObject({ method: 'GET', url: 'http://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:http' });
|
||||
expect(httpsGet).toMatchObject({ method: 'GET', url: 'https://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:https' });
|
||||
@@ -195,11 +195,11 @@ describe('Feature: HTTP Route Sync', () => {
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
// 5 methods × 2 protocols = 10
|
||||
expect(requests).toHaveLength(10);
|
||||
const httpRequests = requests.filter((r: any) => r.konnectRouteKey.endsWith(':http'));
|
||||
const httpsRequests = requests.filter((r: any) => r.konnectRouteKey.endsWith(':https'));
|
||||
const httpRequests = requests.filter(r => r.konnectRouteKey?.endsWith(':http'));
|
||||
const httpsRequests = requests.filter(r => r.konnectRouteKey?.endsWith(':https'));
|
||||
expect(httpRequests).toHaveLength(5);
|
||||
expect(httpsRequests).toHaveLength(5);
|
||||
const methods = httpRequests.map((r: any) => r.method).sort();
|
||||
const methods = httpRequests.map(r => r.method).sort();
|
||||
expect(methods).toEqual(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']);
|
||||
for (const req of requests) {
|
||||
expect(req.name).toBe('/methods-null');
|
||||
@@ -234,8 +234,8 @@ describe('Feature: HTTP Route Sync', () => {
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
// 1 method × 2 protocols = 2
|
||||
expect(requests).toHaveLength(2);
|
||||
const httpReq = requests.find((r: any) => r.url.startsWith('http://'));
|
||||
const httpsReq = requests.find((r: any) => r.url.startsWith('https://'));
|
||||
const httpReq = requests.find(r => r.url.startsWith('http://'));
|
||||
const httpsReq = requests.find(r => r.url.startsWith('https://'));
|
||||
expect(httpReq).toMatchObject({ url: 'http://{{ _.proxy_host }}', name: 'Route route-1' });
|
||||
expect(httpsReq).toMatchObject({ url: 'https://{{ _.proxy_host }}', name: 'Route route-1' });
|
||||
for (const req of requests) {
|
||||
@@ -723,7 +723,7 @@ describe('Feature: Idempotent Sync (Route Keying)', () => {
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
const keys = requests.map((r: any) => r.konnectRouteKey);
|
||||
const keys = requests.map(r => r.konnectRouteKey);
|
||||
expect(keys).toContain('route-uuid-1:GET:/api/v1/users:http');
|
||||
expect(keys).toContain('route-uuid-1:POST:/api/v1/users:http');
|
||||
});
|
||||
@@ -738,7 +738,7 @@ describe('Feature: Idempotent Sync (Route Keying)', () => {
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(5);
|
||||
const keys = requests.map((r: any) => r.konnectRouteKey).sort();
|
||||
const keys = requests.map(r => r.konnectRouteKey).sort();
|
||||
expect(keys).toEqual([
|
||||
'route-uuid-2:DELETE:/api:http',
|
||||
'route-uuid-2:GET:/api:http',
|
||||
@@ -823,11 +823,11 @@ describe('Feature: gRPC Route Sync', () => {
|
||||
|
||||
const grpcRequests = konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(grpcRequests).toHaveLength(2);
|
||||
const keys = grpcRequests.map((r: any) => r.konnectRouteKey).sort();
|
||||
const keys = grpcRequests.map(r => r.konnectRouteKey).sort();
|
||||
expect(keys).toContain('route-uuid-3:grpc:/addsvc.Add/Sum:grpc');
|
||||
expect(keys).toContain('route-uuid-3:grpc:/addsvc.Add/Sum:grpcs');
|
||||
const grpcReq = grpcRequests.find((r: any) => r.konnectRouteKey.endsWith(':grpc'));
|
||||
const grpcsReq = grpcRequests.find((r: any) => r.konnectRouteKey.endsWith(':grpcs'));
|
||||
const grpcReq = grpcRequests.find(r => r.konnectRouteKey?.endsWith(':grpc'));
|
||||
const grpcsReq = grpcRequests.find(r => r.konnectRouteKey?.endsWith(':grpcs'));
|
||||
expect(grpcReq!.url).toBe('grpc://{{ _.grpc_proxy_host }}');
|
||||
expect(grpcsReq!.url).toBe('grpcs://{{ _.grpcs_proxy_host }}');
|
||||
});
|
||||
@@ -869,7 +869,7 @@ describe('Feature: gRPC Route Sync', () => {
|
||||
|
||||
const grpcRequests = konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(grpcRequests).toHaveLength(2);
|
||||
const names = grpcRequests.map((r: any) => r.name).sort();
|
||||
const names = grpcRequests.map(r => r.name).sort();
|
||||
expect(names).toEqual(['/hello.HelloService/LotsOfGreetings', '/hello.HelloService/LotsOfReplies']);
|
||||
});
|
||||
|
||||
@@ -960,11 +960,11 @@ describe('Feature: WebSocket Route Sync', () => {
|
||||
|
||||
const wsRequests = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(wsRequests).toHaveLength(2);
|
||||
const keys = wsRequests.map((r: any) => r.konnectRouteKey).sort();
|
||||
const keys = wsRequests.map(r => r.konnectRouteKey).sort();
|
||||
expect(keys).toContain('route-uuid-4:ws:/ws/mixed:ws');
|
||||
expect(keys).toContain('route-uuid-4:ws:/ws/mixed:wss');
|
||||
const wsReq = wsRequests.find((r: any) => r.konnectRouteKey.endsWith(':ws'));
|
||||
const wssReq = wsRequests.find((r: any) => r.konnectRouteKey.endsWith(':wss'));
|
||||
const wsReq = wsRequests.find(r => r.konnectRouteKey?.endsWith(':ws'));
|
||||
const wssReq = wsRequests.find(r => r.konnectRouteKey?.endsWith(':wss'));
|
||||
expect(wsReq!.url).toBe('ws://{{ _.proxy_host }}/ws/mixed');
|
||||
expect(wssReq!.url).toBe('wss://{{ _.proxy_host }}/ws/mixed');
|
||||
});
|
||||
@@ -1006,7 +1006,7 @@ describe('Feature: WebSocket Route Sync', () => {
|
||||
|
||||
const wsRequests = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(wsRequests).toHaveLength(2);
|
||||
const urls = wsRequests.map((r: any) => r.url).sort();
|
||||
const urls = wsRequests.map(r => r.url).sort();
|
||||
expect(urls).toEqual(['ws://{{ _.proxy_host }}/ws/multi-v1', 'ws://{{ _.proxy_host }}/ws/multi-v2']);
|
||||
});
|
||||
|
||||
@@ -1376,12 +1376,12 @@ describe('Feature: Wildcard and Edge-Case Hosts', () => {
|
||||
// ─── Feature: Expression-Based Routes ──────────────────────────────────────
|
||||
|
||||
describe('Feature: Expression-Based Routes', () => {
|
||||
it('Scenario: Expression route — falls through as methods null', async () => {
|
||||
it('Scenario: Simple method+path expression — creates 1 targeted request', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.path == "/foo" && http.method == "GET"',
|
||||
expression: 'http.method == "GET" && http.path == "/foo"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
name: 'Foo Route',
|
||||
@@ -1390,14 +1390,143 @@ describe('Feature: Expression-Based Routes', () => {
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toMatchObject({ method: 'GET', name: '/foo' });
|
||||
expect(requests[0].url).toContain('/foo');
|
||||
expect(requests[0].name).toBe('/foo');
|
||||
});
|
||||
|
||||
it('Scenario: Path-only expression — defaults to all 5 methods', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.path == "/api/users"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(5);
|
||||
for (const req of requests) {
|
||||
expect(req.name).toBe('Foo Route');
|
||||
expect(req.url).toContain('/api/users');
|
||||
}
|
||||
});
|
||||
|
||||
it('Scenario: Expression route with stream protocol — skipped', async () => {
|
||||
it('Scenario: Multiple methods via OR expression', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.method == "GET" || http.method == "POST"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(2);
|
||||
const methods = requests.map(r => r.method).sort();
|
||||
expect(methods).toEqual(['GET', 'POST']);
|
||||
});
|
||||
|
||||
it('Scenario: Host expression — sets Host header on request', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.host == "api.example.com" && http.method == "GET"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'api.example.com' }]));
|
||||
});
|
||||
|
||||
it('Scenario: Header expression — sets extracted header on request', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.headers.x_tenant == "acme" && http.method == "GET" && http.path == "/api"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].headers).toEqual(expect.arrayContaining([{ name: 'x-tenant', value: 'acme' }]));
|
||||
});
|
||||
|
||||
it('Scenario: Unparseable expression — skipped (no requests created)', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'net.src.ip in 10.0.0.0/8',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0);
|
||||
expect(result.routes.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('Scenario: Partial expression (method extractable, rest unparseable) — creates request', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].method).toBe('GET');
|
||||
});
|
||||
|
||||
it('Scenario: Both protocols — creates requests for each', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http', 'https'],
|
||||
expression: 'http.method == "GET" && http.path == "/foo"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(2);
|
||||
const protocols = requests.map(r => r.konnectRouteKey?.split(':').pop()).sort();
|
||||
expect(protocols).toEqual(['http', 'https']);
|
||||
});
|
||||
|
||||
it('Scenario: Stream protocol — skipped', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
@@ -1413,4 +1542,64 @@ describe('Feature: Expression-Based Routes', () => {
|
||||
expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0);
|
||||
expect(result.routes.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('Scenario: Prefix path expression — creates requests at that path', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
expression: 'http.path ^= "/api/v1"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(5);
|
||||
for (const req of requests) {
|
||||
expect(req.url).toContain('/api/v1');
|
||||
}
|
||||
});
|
||||
|
||||
it('Scenario: Repeated predicates in OR expansion — deduplicates methods/paths/hosts', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['http'],
|
||||
// Each branch repeats the same method and path — a common pattern when
|
||||
// parenthesised OR expansions duplicate shared predicates.
|
||||
expression:
|
||||
'(http.method == "GET" && http.path == "/api" && http.host == "a.example.com") || ' +
|
||||
'(http.method == "GET" && http.path == "/api" && http.host == "a.example.com")',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
// After dedup: 1 method × 1 path × 1 protocol = 1 request (not 4)
|
||||
const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }));
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toMatchObject({ method: 'GET', url: 'http://{{ _.proxy_host }}/api' });
|
||||
});
|
||||
|
||||
it('Scenario: tls.sni expression — skipped', async () => {
|
||||
vi.stubGlobal('fetch', mockFetch(
|
||||
[makeCp()], [makeService()],
|
||||
[makeRoute({
|
||||
protocols: ['https'],
|
||||
expression: 'tls.sni == "secure.example.com" && http.method == "GET"',
|
||||
paths: null,
|
||||
methods: null,
|
||||
})],
|
||||
));
|
||||
|
||||
const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID });
|
||||
|
||||
expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0);
|
||||
expect(result.routes.skipped).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { KonnectRoute } from './api';
|
||||
|
||||
export interface ExtractedRouteFields {
|
||||
methods: string[] | null;
|
||||
paths: string[] | null;
|
||||
hosts: string[] | null;
|
||||
headers: Record<string, string[]> | null;
|
||||
}
|
||||
|
||||
export type ApplyExpressionResult =
|
||||
| { syncable: true; route: KonnectRoute }
|
||||
| { syncable: false; routeName: string; reason: string };
|
||||
|
||||
/**
|
||||
* Extracts traditional route fields from a Kong expressions router DSL string.
|
||||
*
|
||||
* Handles flat AND/OR combinations of simple equality comparisons:
|
||||
* http.method == "GET"
|
||||
* http.path == "/foo"
|
||||
* http.path ^= "/api" (prefix match — treated as exact path for URL construction)
|
||||
* http.host == "api.example.com"
|
||||
* http.headers.<name> == "<value>"
|
||||
*
|
||||
* `tls.sni` presence is detected separately by `applyExpressionFields` — routes that
|
||||
* match on SNI are skipped, since Insomnia cannot set a TLS SNI override.
|
||||
*
|
||||
* Unsupported predicates (!=, ~, in, any(), net.*, etc.) are silently ignored;
|
||||
* their corresponding fields remain null so the caller can apply defaults.
|
||||
*
|
||||
* Known limitation: cross-field AND-within-OR expressions are over-approximated.
|
||||
* e.g. `(http.method == "GET" && http.path == "/v1") || (http.method == "POST" && http.path == "/v2")`
|
||||
* yields methods: ["GET","POST"], paths: ["/v1","/v2"] → 4 requests instead of 2.
|
||||
* In practice it seems more likely that this would be two separate routes.
|
||||
*/
|
||||
export function extractFieldsFromExpression(expression: string): ExtractedRouteFields {
|
||||
const methodMatches = [...new Set([...expression.matchAll(/http\.method\s*==\s*"([A-Z]+)"/g)].map(m => m[1]))];
|
||||
const pathExact = [...expression.matchAll(/http\.path\s*==\s*"([^"]+)"/g)].map(m => m[1]);
|
||||
const pathPrefix = [...expression.matchAll(/http\.path\s*\^=\s*"([^"]+)"/g)].map(m => m[1]);
|
||||
const hostMatches = [...new Set([...expression.matchAll(/http\.host\s*==\s*"([^"]+)"/g)].map(m => m[1]))];
|
||||
const headerMatches = [...expression.matchAll(/http\.headers\.(\w+)\s*==\s*"([^"]+)"/g)];
|
||||
|
||||
const allPaths = [...new Set([...pathExact, ...pathPrefix])];
|
||||
|
||||
let headers: Record<string, string[]> | null = null;
|
||||
if (headerMatches.length > 0) {
|
||||
headers = {};
|
||||
for (const match of headerMatches) {
|
||||
const name = match[1].replace(/_/g, '-').toLowerCase();
|
||||
if (!headers[name]) {
|
||||
headers[name] = [];
|
||||
}
|
||||
headers[name].push(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
methods: methodMatches.length > 0 ? methodMatches : null,
|
||||
paths: allPaths.length > 0 ? allPaths : null,
|
||||
hosts: hostMatches.length > 0 ? hostMatches : null,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the route has an expression, extracts fields from it and returns the merged route.
|
||||
* Returns `syncable: false` when:
|
||||
* - The expression contains `tls.sni` — Insomnia cannot set a TLS SNI override.
|
||||
* - The expression yields no usable fields — creating fallback requests would be misleading.
|
||||
*/
|
||||
export function applyExpressionFields(route: KonnectRoute): ApplyExpressionResult {
|
||||
if (!route.expression) {
|
||||
return { syncable: true, route };
|
||||
}
|
||||
|
||||
if (/\btls\.sni\b/.test(route.expression)) {
|
||||
return {
|
||||
syncable: false,
|
||||
routeName: route.name ?? `Route ${route.id}`,
|
||||
reason: 'Expression route uses tls.sni matching — unsupported in Insomnia',
|
||||
};
|
||||
}
|
||||
|
||||
const extracted = extractFieldsFromExpression(route.expression);
|
||||
|
||||
if (!extracted.methods && !extracted.paths && !extracted.hosts && !extracted.headers) {
|
||||
return {
|
||||
syncable: false,
|
||||
routeName: route.name ?? `Route ${route.id}`,
|
||||
reason: 'Expression route — no extractable fields (method/path/host/header)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
syncable: true,
|
||||
route: {
|
||||
...route,
|
||||
methods: extracted.methods,
|
||||
paths: extracted.paths,
|
||||
hosts: extracted.hosts,
|
||||
headers: extracted.headers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type KonnectRoute,
|
||||
type KonnectService,
|
||||
} from './api';
|
||||
import { applyExpressionFields } from './expression-parser';
|
||||
import {
|
||||
buildRequestName,
|
||||
deriveProxyVarDefaults,
|
||||
@@ -378,35 +379,46 @@ async function syncServiceWorkspace(
|
||||
for (const route of incomingRoutes) {
|
||||
signal?.throwIfAborted();
|
||||
incomingRouteIds.add(route.id);
|
||||
const isL4 = route.protocols.every(p => L4_PROTOCOLS.has(p));
|
||||
const isGrpc = route.protocols.some(p => p === 'grpc' || p === 'grpcs');
|
||||
const isWs = route.protocols.some(p => p === 'ws' || p === 'wss');
|
||||
|
||||
const routeName = routeDisplayName(route);
|
||||
const expressionResult = applyExpressionFields(route);
|
||||
if (!expressionResult.syncable) {
|
||||
counts.routes.skipped++;
|
||||
skippedRoutes.push({ routeName: expressionResult.routeName, reason: expressionResult.reason, serviceName });
|
||||
continue;
|
||||
}
|
||||
const effectiveRoute = expressionResult.route;
|
||||
|
||||
const isL4 = effectiveRoute.protocols.every(p => L4_PROTOCOLS.has(p));
|
||||
const isGrpc = effectiveRoute.protocols.some(p => p === 'grpc' || p === 'grpcs');
|
||||
const isWs = effectiveRoute.protocols.some(p => p === 'ws' || p === 'wss');
|
||||
|
||||
const routeName = routeDisplayName(effectiveRoute);
|
||||
|
||||
if (isL4) {
|
||||
counts.routes.skipped++;
|
||||
skippedRoutes.push({ routeName, reason: `Unsupported protocol: ${route.protocols.join(', ')}`, serviceName });
|
||||
skippedRoutes.push({ routeName, reason: `Unsupported protocol: ${effectiveRoute.protocols.join(', ')}`, serviceName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Routes matched by SNI cannot be represented — Insomnia derives SNI implicitly
|
||||
// from the URL hostname and has no SNI override.
|
||||
if ((route.snis?.length ?? 0) > 0) {
|
||||
// Note: expression-router tls.sni is caught earlier in applyExpressionFields;
|
||||
// this check covers the traditional router's snis field.
|
||||
if ((effectiveRoute.snis?.length ?? 0) > 0) {
|
||||
counts.routes.skipped++;
|
||||
skippedRoutes.push({ routeName, reason: 'Route uses SNI matching — unsupported in Insomnia', serviceName });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isGrpc) {
|
||||
await syncGrpcRoute(route, workspace._id, existingData.maps.grpc, counts.routes, incomingKeys);
|
||||
await syncGrpcRoute(effectiveRoute, workspace._id, existingData.maps.grpc, counts.routes, incomingKeys);
|
||||
} else {
|
||||
// Host header only applies to HTTP/WS — gRPC uses :authority which Insomnia derives from the URL
|
||||
const headers = [
|
||||
...(route.hosts?.[0] ? [{ name: 'host', value: route.hosts[0] }] : []),
|
||||
...Object.entries(route.headers ?? {}).map(([name, values]) => ({ name: name.toLowerCase(), value: values[0] })),
|
||||
...(effectiveRoute.hosts?.[0] ? [{ name: 'host', value: effectiveRoute.hosts[0] }] : []),
|
||||
...Object.entries(effectiveRoute.headers ?? {}).map(([name, values]) => ({ name: name.toLowerCase(), value: values[0] })),
|
||||
];
|
||||
await (isWs ? syncWsRoute(route, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys) : syncHttpRoute(route, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys));
|
||||
await (isWs ? syncWsRoute(effectiveRoute, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys) : syncHttpRoute(effectiveRoute, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user