From 02aa32f39361e1a1a35bd659620b5cbfc2ddcd0a Mon Sep 17 00:00:00 2001 From: Shelby <13246465+shelby-moore@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:50:01 -0700 Subject: [PATCH] feat: konnect integration expressions support (#9830) --- .../__tests__/expression-parser.test.ts | 173 +++++++++++++ .../src/konnect/__tests__/sync.test.ts | 237 ++++++++++++++++-- .../insomnia/src/konnect/expression-parser.ts | 103 ++++++++ packages/insomnia/src/konnect/sync.ts | 32 ++- 4 files changed, 511 insertions(+), 34 deletions(-) create mode 100644 packages/insomnia/src/konnect/__tests__/expression-parser.test.ts create mode 100644 packages/insomnia/src/konnect/expression-parser.ts diff --git a/packages/insomnia/src/konnect/__tests__/expression-parser.test.ts b/packages/insomnia/src/konnect/__tests__/expression-parser.test.ts new file mode 100644 index 0000000000..63af101c12 --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/expression-parser.test.ts @@ -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(); + } + }); +}); diff --git a/packages/insomnia/src/konnect/__tests__/sync.test.ts b/packages/insomnia/src/konnect/__tests__/sync.test.ts index 5de789ddf5..419afa2fe7 100644 --- a/packages/insomnia/src/konnect/__tests__/sync.test.ts +++ b/packages/insomnia/src/konnect/__tests__/sync.test.ts @@ -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); + }); }); diff --git a/packages/insomnia/src/konnect/expression-parser.ts b/packages/insomnia/src/konnect/expression-parser.ts new file mode 100644 index 0000000000..8d1048942b --- /dev/null +++ b/packages/insomnia/src/konnect/expression-parser.ts @@ -0,0 +1,103 @@ +import type { KonnectRoute } from './api'; + +export interface ExtractedRouteFields { + methods: string[] | null; + paths: string[] | null; + hosts: string[] | null; + headers: Record | 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. == "" + * + * `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 | 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, + }, + }; +} diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index 4c3515c9aa..0ee60e50e1 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -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)); } }