diff --git a/packages/insomnia-data/__tests__/request.test.ts b/packages/insomnia-data/__tests__/request.test.ts index 650f9621a3..f06f4f9ab0 100644 --- a/packages/insomnia-data/__tests__/request.test.ts +++ b/packages/insomnia-data/__tests__/request.test.ts @@ -22,7 +22,7 @@ import type { RequestHeader, RequestParameter, } from 'insomnia-data'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { v4 as uuidv4 } from 'uuid'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -832,4 +832,47 @@ describe('Request Model - Comprehensive Tests', () => { expect(deletedRequest).toBeUndefined(); }); }); + + describe('applyPathParametersToUrl', () => { + const { applyPathParametersToUrl } = models.request; + + it('substitutes a single path parameter', () => { + expect( + applyPathParametersToUrl('ws://localhost/api/users/:id', [{ name: 'id', value: '1234' }]), + ).toBe('ws://localhost/api/users/1234'); + }); + + it('substitutes multiple path parameters', () => { + expect( + applyPathParametersToUrl('https://api.example.com/:org/:repo', [ + { name: 'org', value: 'kong' }, + { name: 'repo', value: 'insomnia' }, + ]), + ).toBe('https://api.example.com/kong/insomnia'); + }); + + it('URL-encodes path parameter values', () => { + expect( + applyPathParametersToUrl('https://api.example.com/users/:id', [{ name: 'id', value: 'a/b c' }]), + ).toBe('https://api.example.com/users/a%2Fb%20c'); + }); + + it('leaves unmatched :param segments unchanged', () => { + expect( + applyPathParametersToUrl('https://api.example.com/users/:id', [{ name: 'other', value: '1234' }]), + ).toBe('https://api.example.com/users/:id'); + }); + + it('leaves :param segments unchanged when value is empty', () => { + expect( + applyPathParametersToUrl('https://api.example.com/users/:id', [{ name: 'id', value: '' }]), + ).toBe('https://api.example.com/users/:id'); + }); + + it('returns url unchanged when pathParameters is empty or undefined', () => { + const url = 'https://api.example.com/users/:id'; + expect(applyPathParametersToUrl(url, [])).toBe(url); + expect(applyPathParametersToUrl(url)).toBe(url); + }); + }); }); diff --git a/packages/insomnia-data/src/models/request.ts b/packages/insomnia-data/src/models/request.ts index d0139f0005..9fe124f502 100644 --- a/packages/insomnia-data/src/models/request.ts +++ b/packages/insomnia-data/src/models/request.ts @@ -216,6 +216,24 @@ export interface RequestPathParameter { export const PATH_PARAMETER_REGEX = /\/:[^/?#:]+/g; +/** Replace `:param` url segments with their URL-encoded values; unmatched or empty params are left unchanged */ +export const applyPathParametersToUrl = ( + url: string, + pathParameters?: RequestPathParameter[], +): string => { + if (!pathParameters?.length) { + return url; + } + return url.replace(PATH_PARAMETER_REGEX, match => { + const paramName = match.replace('/:', ''); + const param = pathParameters.find(p => p.name === paramName); + if (param?.value) { + return `/${encodeURIComponent(param.value)}`; + } + return match; + }); +}; + export const getPathParametersFromUrl = (url: string): string[] => { // Find all path parameters in the URL. Path parameters are defined as segments of the URL that start with a colon. const urlPathParameters = diff --git a/packages/insomnia-smoke-test/fixtures/websockets.yaml b/packages/insomnia-smoke-test/fixtures/websockets.yaml index 23a0963520..b5a6ff5209 100644 --- a/packages/insomnia-smoke-test/fixtures/websockets.yaml +++ b/packages/insomnia-smoke-test/fixtures/websockets.yaml @@ -79,6 +79,21 @@ collection: headers: - name: duration value: "5" + - url: ws://localhost:4010/chat/:id + name: path-param + meta: + id: ws-req_path_param_test + created: 1666695713693 + modified: 1666696265532 + pathParameters: + - name: id + value: "1234" + settings: + encodeUrl: true + followRedirects: global + cookies: + send: true + store: true cookieJar: name: Default Jar meta: diff --git a/packages/insomnia-smoke-test/server/websocket.ts b/packages/insomnia-smoke-test/server/websocket.ts index e0132a554e..fdd786e455 100644 --- a/packages/insomnia-smoke-test/server/websocket.ts +++ b/packages/insomnia-smoke-test/server/websocket.ts @@ -95,6 +95,9 @@ const upgrade = (wss: WebSocketServer, request: IncomingMessage, socket: Socket, } return redirectOnSuccess(socket); } + if (request.url === '/chat/1234') { + return redirectOnSuccess(socket); + } wss.handleUpgrade(request, socket, head, ws => { wss.emit('connection', ws, request); }); diff --git a/packages/insomnia-smoke-test/tests/smoke/websocket.test.ts b/packages/insomnia-smoke-test/tests/smoke/websocket.test.ts index 7d959cac12..d628c4b2ac 100644 --- a/packages/insomnia-smoke-test/tests/smoke/websocket.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/websocket.test.ts @@ -51,10 +51,18 @@ test('can make websocket connection', async ({ app, page, insomnia }) => { await page.getByRole('tab', { name: 'Console' }).click(); await expect.soft(responseBody).toContainText('WebSocket connection established'); + // Can connect with path parameters substituted in the URL + await insomnia.navigationSidebar.clickRequestOrFolder('path-param'); + await expect.soft(page.locator('.app')).toContainText('ws://localhost:4010/chat/:id'); + await page.click('text=Connect'); + await expect.soft(statusTag).toContainText('101 Switching Protocols'); + await page.getByRole('tab', { name: 'Console' }).click(); + await expect.soft(responseBody).toContainText('WebSocket connection established'); + const webSocketActiveConnections = page.getByTestId('WebSocketSpinner__Connected'); - // Basic auth, Bearer auth, and Redirect connections are displayed as open - await expect.soft(webSocketActiveConnections).toHaveCount(3); + // Basic auth, Bearer auth, Redirect, and path-param connections are displayed as open + await expect.soft(webSocketActiveConnections).toHaveCount(4); // Can disconnect from all connections await page.locator('button[name="DisconnectDropdown__DropdownButton"]').click(); diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index 08ed665101..0b3db5ac69 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -31,7 +31,7 @@ import { setDefaultProtocol } from '../utils/url/protocol'; import { CONTENT_TYPE_GRAPHQL, JSON_ORDER_SEPARATOR } from './constants'; import { database as db } from './database'; -const { PATH_PARAMETER_REGEX } = models.request; +const { applyPathParametersToUrl } = models.request; const { isRequestGroup } = models.requestGroup; export async function buildRenderContext({ @@ -611,22 +611,7 @@ export async function getRenderedRequestAndContext({ // Default the proto if it doesn't exist renderedRequest.url = setDefaultProtocol(renderedRequest.url); - // Render path parameters - if (renderedRequest.pathParameters) { - // Replace path parameters in URL with their rendered values - // Path parameters are path segments that start with a colon, e.g. :id - renderedRequest.url = renderedRequest.url.replace(PATH_PARAMETER_REGEX, match => { - const paramName = match.replace('\/:', ''); - const param = renderedRequest.pathParameters?.find(p => p.name === paramName); - - if (param && param.value) { - // The parameter value needs to be URL encoded - return `/${encodeURIComponent(param.value)}`; - } - - return match; - }); - } + renderedRequest.url = applyPathParametersToUrl(renderedRequest.url, renderedRequest.pathParameters); return { context: renderContext, diff --git a/packages/insomnia/src/main/network/socket-io.ts b/packages/insomnia/src/main/network/socket-io.ts index be3ed6c43a..7ef313e4ad 100644 --- a/packages/insomnia/src/main/network/socket-io.ts +++ b/packages/insomnia/src/main/network/socket-io.ts @@ -149,6 +149,7 @@ interface OpenSocketIORequestOptions { cookieJar: CookieJar; path?: string; initialPayload?: string; + suppressUserAgent?: boolean; } const getCertificates = async ({ @@ -291,7 +292,7 @@ const openSocketIOConnection = async ( const lowerCasedEnabledHeaders = headers .filter(({ name, disabled }) => Boolean(name) && !disabled) .reduce(reduceArrayToLowerCaseKeyedDictionary, {}); - if (!request.disableUserAgentHeader && !hasUserAgentHeader) { + if (!options.suppressUserAgent && !request.disableUserAgentHeader && !hasUserAgentHeader) { lowerCasedEnabledHeaders['user-agent'] = `insomnia/${version}`; } diff --git a/packages/insomnia/src/main/network/websocket.ts b/packages/insomnia/src/main/network/websocket.ts index adfcc9c543..109eede666 100644 --- a/packages/insomnia/src/main/network/websocket.ts +++ b/packages/insomnia/src/main/network/websocket.ts @@ -147,6 +147,7 @@ interface OpenWebSocketRequestOptions { cookieJar: CookieJar; initialPayload?: string; isGraphqlSubscriptionRequest?: boolean; + suppressUserAgent?: boolean; } const openWebSocketConnection = async ( _event: Electron.IpcMainInvokeEvent, @@ -234,7 +235,7 @@ const openWebSocketConnection = async ( const lowerCasedEnabledHeaders = headers .filter(({ name, disabled }) => Boolean(name) && !disabled) .reduce(reduceArrayToLowerCaseKeyedDictionary, {}); - if (!request.disableUserAgentHeader && !hasUserAgentHeader) { + if (!options.suppressUserAgent && !request.disableUserAgentHeader && !hasUserAgentHeader) { lowerCasedEnabledHeaders['user-agent'] = `insomnia/${version}`; } const settings = await services.settings.get(); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx index 7c9ea38b80..6d8385b9e3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx @@ -47,6 +47,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) headers: rendered.headers, authentication: rendered.authentication, cookieJar: rendered.cookieJar, + suppressUserAgent: rendered.suppressUserAgent, }); window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestExecuted, @@ -74,6 +75,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) }), authentication: rendered.authentication, cookieJar: rendered.cookieJar, + suppressUserAgent: rendered.suppressUserAgent, }); window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestExecuted, @@ -108,6 +110,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) authentication: rendered.authentication, query: rendered.query || {}, path: rendered.path, + suppressUserAgent: rendered.suppressUserAgent, }); window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestExecuted, diff --git a/packages/insomnia/src/ui/components/rendered-query-string.tsx b/packages/insomnia/src/ui/components/rendered-query-string.tsx index 3cff81e09e..6323584941 100644 --- a/packages/insomnia/src/ui/components/rendered-query-string.tsx +++ b/packages/insomnia/src/ui/components/rendered-query-string.tsx @@ -85,22 +85,7 @@ export const RenderedQueryString: FC = ({ request }) => { } const { parameters, pathParameters, authQueryParams: renderedAuthQueryParams } = result; - let { url } = result; - - if (pathParameters) { - // Replace path parameters in URL with their rendered values - // Path parameters are path segments that start with a colon, e.g. :id - url = url.replace(models.request.PATH_PARAMETER_REGEX, match => { - const pathParam = match.replace('/:', ''); - const param = pathParameters?.find(p => p.name === pathParam); - - if (param && param.value) { - return `/${encodeURIComponent(param.value)}`; - } - // The parameter should also be URL encoded - return match; - }); - } + const url = models.request.applyPathParametersToUrl(result.url, pathParameters); const mergedParams = [...parameters, ...renderedAuthQueryParams]; const qs = buildQueryStringFromParams(mergedParams, false, { encodeParams: request.settingEncodeUrl }); diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index 62b66e0dbb..1a69c72456 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -1,4 +1,3 @@ -import type { Request, RequestGroup } from 'insomnia-data'; import { models, services } from 'insomnia-data'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { Button, Link } from 'react-aria-components'; @@ -19,15 +18,13 @@ import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client import { showSettingsModal } from '~/ui/components/modals/settings-modal'; import { recordProjectRecentRequest } from '~/ui/utils/recent-project-requests'; -import { database as db } from '../../common/database'; -import { getOrInheritAuthentication, getOrInheritHeaders } from '../../network/network'; import { useWorkspaceLoaderData } from '../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { type RequestLoaderData, useRequestLoaderData, } from '../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; import { AnalyticsEvent } from '../../ui/analytics'; -import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../utils/try-interpolate'; +import { renderRealtimeConnectPayload } from '../../utils/render-realtime-connect'; import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../utils/url/querystring'; import { useInsomniaTabContext } from '../context/app/insomnia-tab-context'; import { useReadyState } from '../hooks/use-ready-state'; @@ -43,7 +40,6 @@ import { InputVaultKeyModal } from './modals/input-vault-key-modal'; import { PromptModal } from './modals/prompt-modal'; import { VariableMissingErrorModal } from './modals/variable-missing-error-modal'; -const { isRequestGroup } = models.requestGroup; const { isEventStreamRequest, isGraphqlSubscriptionRequest } = models.request; interface Props { handleAutocompleteUrls: () => Promise; @@ -194,24 +190,10 @@ export const RequestUrlBar = forwardRef( const startListening = async () => { const environmentId = activeEnvironment._id; const workspaceId = activeWorkspace._id; - // Render any Liquid template tags in the url/headers/authentication settings/cookies - const workspaceCookieJar = await services.cookieJar.getOrCreateForParentId(workspaceId); - - const ancestors = await db.withAncestors(activeRequest, [models.requestGroup.type]); - // check for authentication overrides in parent folders - const requestGroups = ancestors.filter(isRequestGroup) as RequestGroup[]; - activeRequest.authentication = getOrInheritAuthentication({ request: activeRequest, requestGroups }); - activeRequest.headers = getOrInheritHeaders({ request: activeRequest, requestGroups }); - const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ + const rendered = await renderRealtimeConnectPayload({ request: activeRequest, environmentId, - payload: { - url: activeRequest.url, - headers: activeRequest.headers, - authentication: activeRequest.authentication, - parameters: activeRequest.parameters.filter(p => !p.disabled), - workspaceCookieJar, - }, + workspaceId, }); rendered && connect({ diff --git a/packages/insomnia/src/ui/components/websockets/action-bar.tsx b/packages/insomnia/src/ui/components/websockets/action-bar.tsx index 5df6e218cc..3c6232ed02 100644 --- a/packages/insomnia/src/ui/components/websockets/action-bar.tsx +++ b/packages/insomnia/src/ui/components/websockets/action-bar.tsx @@ -1,5 +1,4 @@ import type { SocketIORequest, WebSocketRequest } from 'insomnia-data'; -import { services } from 'insomnia-data'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'; import { useParams } from 'react-router'; @@ -10,7 +9,7 @@ import { import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client/codemirror/one-line-editor'; import { recordProjectRecentRequest } from '~/ui/utils/recent-project-requests'; -import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate'; +import { renderRealtimeConnectPayload } from '../../../utils/render-realtime-connect'; import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring'; import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder'; @@ -60,20 +59,10 @@ export const WebSocketActionBar = forwardRef { - // Render any Liquid template tags in the url/headers/authentication settings/cookies - - const workspaceCookieJar = await services.cookieJar.getOrCreateForParentId(workspaceId); - // Render any Liquid template tags in the url/headers/authentication settings/cookies - const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ + const rendered = await renderRealtimeConnectPayload({ request, environmentId, - payload: { - url: request.url, - headers: request.headers, - authentication: request.authentication, - parameters: request.parameters.filter(p => !p.disabled), - workspaceCookieJar, - }, + workspaceId, }); if (request.type === 'WebSocketRequest' && rendered) { return { diff --git a/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx b/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx index 3aa97ad514..e93f9945ff 100644 --- a/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx +++ b/packages/insomnia/src/ui/components/websockets/websocket-request-pane.tsx @@ -19,6 +19,7 @@ import { type WebSocketRequestLoaderData, } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; import { RenderError } from '../../../templating/render-error'; +import { renderRealtimeConnectPayload } from '../../../utils/render-realtime-connect'; import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate'; import { buildQueryStringFromParams, @@ -89,18 +90,14 @@ const WebSocketRequestForm: FC = ({ request, previewMode, environment const renderedMessage = await tryToInterpolateRequestOrShowRenderErrorModal({ request, environmentId, payload }); const readyState = await window.main.webSocket.readyState.getCurrent({ requestId: request._id }); if (!readyState) { - const workspaceCookieJar = await services.cookieJar.getOrCreateForParentId(workspaceId); - const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ + const rendered = await renderRealtimeConnectPayload({ request, environmentId, - payload: { - url: request.url, - headers: request.headers, - authentication: request.authentication, - parameters: request.parameters.filter(p => !p.disabled), - workspaceCookieJar, - }, + workspaceId, }); + if (!rendered) { + return; + } window.main.webSocket.open({ requestId: request._id, workspaceId, @@ -109,6 +106,7 @@ const WebSocketRequestForm: FC = ({ request, previewMode, environment authentication: rendered.authentication, cookieJar: rendered.workspaceCookieJar, initialPayload: renderedMessage, + suppressUserAgent: rendered.suppressUserAgent, }); return; } diff --git a/packages/insomnia/src/utils/render-realtime-connect.test.ts b/packages/insomnia/src/utils/render-realtime-connect.test.ts new file mode 100644 index 0000000000..c714d19d48 --- /dev/null +++ b/packages/insomnia/src/utils/render-realtime-connect.test.ts @@ -0,0 +1,187 @@ +import type { WebSocketRequest } from 'insomnia-data'; +import { services } from 'insomnia-data'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { database as db } from '../common/database'; +import { renderRealtimeConnectPayload } from './render-realtime-connect'; +import type * as TryInterpolate from './try-interpolate'; + +const { tryToInterpolateRequestOrShowRenderErrorModal } = vi.hoisted(() => ({ + tryToInterpolateRequestOrShowRenderErrorModal: vi.fn(), +})); + +vi.mock('./try-interpolate', () => ({ + tryToInterpolateRequestOrShowRenderErrorModal, +})); + +vi.mock('../ui/components/modals', () => ({ + showModal: vi.fn(), +})); + +describe('renderRealtimeConnectPayload', () => { + let workspaceId: string; + let environmentId: string; + let request: WebSocketRequest; + + const mockRendered = async (overrides: Record = {}) => { + tryToInterpolateRequestOrShowRenderErrorModal.mockResolvedValue({ + url: 'ws://localhost/api/chat/1234', + headers: [], + authentication: { type: 'none' }, + parameters: [], + pathParameters: [{ name: 'id', value: '1234' }], + workspaceCookieJar: await services.cookieJar.getOrCreateForParentId(workspaceId), + ...overrides, + }); + }; + + beforeEach(async () => { + vi.clearAllMocks(); + await db.init({ inMemoryOnly: true }, true); + + const project = await services.project.create({ + _id: 'proj_render_connect', + name: 'Render Connect Project', + }); + const workspace = await services.workspace.create({ + _id: 'wrk_render_connect', + name: 'Render Connect Workspace', + parentId: project._id, + scope: 'collection', + }); + workspaceId = workspace._id; + const environment = await services.environment.getOrCreateForParentId(workspaceId); + environmentId = environment._id; + + request = await services.webSocketRequest.create({ + _id: 'ws-req_render_connect', + name: 'WS Path Param', + parentId: workspaceId, + url: 'ws://localhost/api/chat/:id', + pathParameters: [{ name: 'id', value: '1234' }], + metaSortKey: 0, + }); + }); + + it('substitutes path parameters in the rendered url', async () => { + await mockRendered({ url: 'ws://localhost/api/chat/:id' }); + + const result = await renderRealtimeConnectPayload({ request, environmentId, workspaceId }); + + expect(result?.url).toBe('ws://localhost/api/chat/1234'); + }); + + it('uses inherited folder auth and headers in the render payload', async () => { + const folder = await services.requestGroup.create({ + _id: 'fld_render_connect', + name: 'Folder', + parentId: workspaceId, + metaSortKey: 0, + authentication: { type: 'bearer', token: 'folder-token', disabled: false }, + headers: [{ name: 'X-Folder-Header', value: 'folder-value' }], + }); + request = await services.webSocketRequest.update(request, { parentId: folder._id }); + await mockRendered(); + + await renderRealtimeConnectPayload({ request, environmentId, workspaceId }); + + expect(tryToInterpolateRequestOrShowRenderErrorModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + authentication: expect.objectContaining({ type: 'bearer', token: 'folder-token' }), + headers: expect.arrayContaining([expect.objectContaining({ name: 'X-Folder-Header' })]), + }), + }), + ); + }); + + it('returns undefined when rendering fails', async () => { + tryToInterpolateRequestOrShowRenderErrorModal.mockImplementation(async () => {}); + + const result = await renderRealtimeConnectPayload({ request, environmentId, workspaceId }); + + expect(result).toBeUndefined(); + }); + + it('computes suppressUserAgent from request settings', async () => { + request = await services.webSocketRequest.update(request, { disableUserAgentHeader: true }); + await mockRendered(); + + const result = await renderRealtimeConnectPayload({ request, environmentId, workspaceId }); + + expect(result?.suppressUserAgent).toBe(true); + }); + + it('suppresses the user agent when all user-agent headers are disabled', async () => { + request = await services.webSocketRequest.update(request, { + headers: [{ name: 'User-Agent', value: 'custom-agent', disabled: true }], + }); + await mockRendered(); + + const result = await renderRealtimeConnectPayload({ request, environmentId, workspaceId }); + + expect(result?.suppressUserAgent).toBe(true); + }); + + it('does not suppress the user agent when an enabled user-agent header exists on a parent folder', async () => { + const folder = await services.requestGroup.create({ + _id: 'fld_render_connect_ua', + name: 'UA Folder', + parentId: workspaceId, + metaSortKey: 0, + headers: [{ name: 'User-Agent', value: 'folder-agent' }], + }); + request = await services.webSocketRequest.update(request, { + parentId: folder._id, + headers: [{ name: 'User-Agent', value: 'custom-agent', disabled: true }], + }); + await mockRendered({ headers: [{ name: 'User-Agent', value: 'folder-agent' }] }); + + const result = await renderRealtimeConnectPayload({ request, environmentId, workspaceId }); + + expect(result?.suppressUserAgent).toBe(false); + }); +}); + +describe('renderRealtimeConnectPayload integration', () => { + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + }); + + it('templates path parameter values from environment variables', async () => { + const actual = await vi.importActual('./try-interpolate'); + tryToInterpolateRequestOrShowRenderErrorModal.mockImplementation( + actual.tryToInterpolateRequestOrShowRenderErrorModal, + ); + + const project = await services.project.create({ + _id: 'proj_render_connect_env', + name: 'Render Connect Env Project', + }); + const workspace = await services.workspace.create({ + _id: 'wrk_render_connect_env', + name: 'Render Connect Env Workspace', + parentId: project._id, + scope: 'collection', + }); + const environment = await services.environment.getOrCreateForParentId(workspace._id); + await services.environment.update(environment, { data: { chatId: '5678' } }); + + const request = await services.webSocketRequest.create({ + _id: 'ws-req_render_connect_env', + name: 'WS Env Path Param', + parentId: workspace._id, + url: 'ws://localhost/api/chat/:id', + pathParameters: [{ name: 'id', value: '{{ _.chatId }}' }], + metaSortKey: 0, + }); + + const result = await renderRealtimeConnectPayload({ + request, + environmentId: environment._id, + workspaceId: workspace._id, + }); + + expect(result?.url).toBe('ws://localhost/api/chat/5678'); + }); +}); diff --git a/packages/insomnia/src/utils/render-realtime-connect.ts b/packages/insomnia/src/utils/render-realtime-connect.ts new file mode 100644 index 0000000000..3fb87ccab5 --- /dev/null +++ b/packages/insomnia/src/utils/render-realtime-connect.ts @@ -0,0 +1,72 @@ +import type { CookieJar, Request, RequestAuthentication, RequestGroup, RequestHeader, RequestParameter, SocketIORequest, WebSocketRequest } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; + +import { database as db } from '../common/database'; +import { getOrInheritAuthentication, getOrInheritHeaders } from '../network/network'; +import { tryToInterpolateRequestOrShowRenderErrorModal } from './try-interpolate'; + +const { applyPathParametersToUrl } = models.request; +const { isRequestGroup, type: requestGroupType } = models.requestGroup; + +export interface RenderedRealtimeConnectPayload { + /** rendered url with path parameters applied */ + url: string; + headers: RequestHeader[]; + authentication: RequestAuthentication; + parameters: RequestParameter[]; + workspaceCookieJar: CookieJar; + suppressUserAgent: boolean; +} + +export async function renderRealtimeConnectPayload({ + request, + environmentId, + workspaceId, +}: { + request: WebSocketRequest | SocketIORequest | Request; + environmentId: string; + workspaceId: string; +}): Promise { + const workspaceCookieJar = await services.cookieJar.getOrCreateForParentId(workspaceId); + const ancestors = await db.withAncestors(request, [ + requestGroupType, + ]); + const requestGroups = ancestors.filter(isRequestGroup); + const headers = getOrInheritHeaders({ request, requestGroups }); + const authentication = getOrInheritAuthentication({ request, requestGroups }); + + const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ + request, + environmentId, + payload: { + url: request.url, + headers, + authentication, + parameters: request.parameters.filter(p => !p.disabled), + pathParameters: request.pathParameters, + workspaceCookieJar, + }, + }); + + if (!rendered) { + return undefined; + } + + // getOrInheritHeaders drops disabled headers, so disabled User-Agent headers are only visible on the raw request/folder headers + const userAgentHeaders = [...requestGroups, request] + .flatMap(doc => doc.headers || []) + .filter(h => h.name?.toLowerCase() === 'user-agent'); + const suppressUserAgent = + Boolean(request.disableUserAgentHeader) || (userAgentHeaders.length > 0 && userAgentHeaders.every(h => h.disabled)); + + const url = applyPathParametersToUrl(rendered.url, rendered.pathParameters); + + return { + url, + headers: rendered.headers, + authentication: rendered.authentication, + parameters: rendered.parameters, + workspaceCookieJar: rendered.workspaceCookieJar, + suppressUserAgent, + }; +}