feat: konnect integration expressions support (#9830)

This commit is contained in:
Shelby authored and GitHub committed 2026-04-20 17:50:01 -07:00
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,
},
};
}
+22 -10
View File
@@ -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));
}
}