diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index d2bd55be0e4..d3aed7997c3 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -219,6 +219,7 @@ export async function render( blacklistPathRegex: RegExp | null = null, errorMode: string = THROW_ON_ERROR, name = '', + ignoreUndefinedEnvVariable: boolean = false, ) { // Make a deep copy so no one gets mad :) const newObj = clone(obj); @@ -244,7 +245,7 @@ export async function render( } else if (typeof x === 'string') { try { // @ts-expect-error -- TSCONVERSION - x = await templating.render(x, { context, path }); + x = await templating.render(x, { context, path, ignoreUndefinedEnvVariable }); // If the variable outputs a tag, render it again. This is a common use // case for environment variables: @@ -301,6 +302,7 @@ interface BaseRenderContextOptions { baseEnvironment?: Environment; purpose?: RenderPurpose; extraInfo?: ExtraRenderInfo; + ignoreUndefinedEnvVariable?: boolean; } interface RenderContextOptions extends BaseRenderContextOptions, Partial> { @@ -471,6 +473,7 @@ export async function getRenderedRequestAndContext( baseEnvironment, extraInfo, purpose, + ignoreUndefinedEnvVariable, }: RenderRequestOptions, ): Promise { const ancestors = await getRenderContextAncestors(request); @@ -500,6 +503,9 @@ export async function getRenderedRequestAndContext( }, renderContext, request.settingDisableRenderRequestBody ? /^body.*/ : null, + THROW_ON_ERROR, + '', + ignoreUndefinedEnvVariable, ); const renderedRequest = renderResult._request; diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index 0aacc7d1c29..fe73eb02a15 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -174,6 +174,7 @@ export const tryToInterpolateRequest = async ( purpose?: RenderPurpose, extraInfo?: ExtraRenderInfo, baseEnvironment?: Environment, + ignoreUndefinedEnvVariable?: boolean, ) => { try { return await getRenderedRequestAndContext({ @@ -182,6 +183,7 @@ export const tryToInterpolateRequest = async ( baseEnvironment, purpose, extraInfo, + ignoreUndefinedEnvVariable, }); } catch (err) { if ('type' in err && err.type === 'render') { diff --git a/packages/insomnia/src/templating/__tests__/utils.test.ts b/packages/insomnia/src/templating/__tests__/utils.test.ts index de65014b1f2..e44dd20a64b 100644 --- a/packages/insomnia/src/templating/__tests__/utils.test.ts +++ b/packages/insomnia/src/templating/__tests__/utils.test.ts @@ -318,3 +318,14 @@ describe('decodeEncoding()', () => { expect(utils.decodeEncoding('')).toBe(''); }); }); + +describe('extractVariableKey()', () => { + beforeEach(globalBeforeEach); + + it('extract nunjucks variable key', () => { + expect(utils.extractVariableKey('{{name}}', 1, 1)).toBe('name'); + expect(utils.extractVariableKey('aaaaaa{{name}}', 1, 7)).toBe('name'); + expect(utils.extractVariableKey('{{name}}\n\n{{age}}', 3, 1)).toBe('age'); + expect(utils.extractVariableKey('', 1, 1)).toBe(''); + }); +}); diff --git a/packages/insomnia/src/templating/index.ts b/packages/insomnia/src/templating/index.ts index 3deea206964..600cb5b8293 100644 --- a/packages/insomnia/src/templating/index.ts +++ b/packages/insomnia/src/templating/index.ts @@ -1,10 +1,14 @@ -import { type Environment } from 'nunjucks'; +import { Environment } from 'nunjucks'; import nunjucks from 'nunjucks/browser/nunjucks'; import * as plugins from '../plugins/index'; import { localTemplateTags } from '../ui/components/templating/local-template-tags'; import BaseExtension from './base-extension'; -import type { NunjucksParsedTag } from './utils'; +import { extractVariableKey, type NunjucksParsedTag } from './utils'; + +export enum RenderErrorSubType { + EnvironmentVariable = 'environmentVariable' +} export class RenderError extends Error { // TODO: unsound definite assignment assertions @@ -18,6 +22,7 @@ export class RenderError extends Error { type!: string; reason!: string; + extraInfo?: Record; } // Some constants @@ -49,6 +54,7 @@ export function render( context?: Record; path?: string; renderMode?: string; + ignoreUndefinedEnvVariable?: boolean; } = {}, ) { const hasNunjucksInterpolationSymbols = text.includes('{{') && text.includes('}}'); @@ -67,7 +73,7 @@ export function render( return new Promise(async (resolve, reject) => { // NOTE: this is added as a breadcrumb because renderString sometimes hangs const id = setTimeout(() => console.log('Warning: nunjucks failed to respond within 5 seconds'), 5000); - const nj = await getNunjucks(renderMode); + const nj = await getNunjucks(renderMode, config.ignoreUndefinedEnvVariable); nj?.renderString(text, templatingContext, (err: Error | null, result: any) => { clearTimeout(id); if (err) { @@ -92,6 +98,13 @@ export function render( }; newError.type = 'render'; newError.reason = reason; + // regard as environment variable missing + if (hasNunjucksInterpolationSymbols && reason === 'undefined') { + newError.extraInfo = { + subType: RenderErrorSubType.EnvironmentVariable, + missingKey: extractVariableKey(text, line, column), + }; + } reject(newError); } else { resolve(result); @@ -130,17 +143,20 @@ export async function getTagDefinitions() { })); } -async function getNunjucks(renderMode: string): Promise { - if (renderMode === RENDER_VARS && nunjucksVariablesOnly) { - return nunjucksVariablesOnly; - } - - if (renderMode === RENDER_TAGS && nunjucksTagsOnly) { - return nunjucksTagsOnly; - } - - if (renderMode === RENDER_ALL && nunjucksAll) { - return nunjucksAll; +async function getNunjucks(renderMode: string, ignoreUndefinedEnvVariable?: boolean): Promise { + let throwOnUndefined = true; + if (ignoreUndefinedEnvVariable) { + throwOnUndefined = false; + } else { + if (renderMode === RENDER_VARS && nunjucksVariablesOnly) { + return nunjucksVariablesOnly; + } + if (renderMode === RENDER_TAGS && nunjucksTagsOnly) { + return nunjucksTagsOnly; + } + if (renderMode === RENDER_ALL && nunjucksAll) { + return nunjucksAll; + } } // ~~~~~~~~~~~~ // @@ -149,7 +165,7 @@ async function getNunjucks(renderMode: string): Promise { const config = { autoescape: false, // Don't escape HTML - throwOnUndefined: true, + throwOnUndefined, // Strict mode tags: { blockStart: '{%', @@ -194,8 +210,11 @@ async function getNunjucks(renderMode: string): Promise { } // ~~~~~~~~~~~~~~~~~~~~ // - // Cache Env and Return // + // Cache Env and Return (when ignoreUndefinedEnvVariable is false) // // ~~~~~~~~~~~~~~~~~~~~ // + if (ignoreUndefinedEnvVariable) { + return nunjucksEnvironment; + } if (renderMode === RENDER_VARS) { nunjucksVariablesOnly = nunjucksEnvironment; } else if (renderMode === RENDER_TAGS) { diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index b32ee45a35f..9768b0342dd 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -276,3 +276,12 @@ export function decodeEncoding(value: T) { return value; } + +export function extractVariableKey(text: string = '', line: number, column: number): string { + const list = text?.split('\n'); + const lineText = list?.[line - 1]; + const errorText = lineText?.slice(column - 1); + const regexVariable = /{{\s*([^ }]+)\s*[^}]*\s*}}/; + const res = errorText?.match(regexVariable); + return res?.[1] || ''; +} diff --git a/packages/insomnia/src/ui/components/modals/variable-missing-error-modal.tsx b/packages/insomnia/src/ui/components/modals/variable-missing-error-modal.tsx new file mode 100644 index 00000000000..df72c8891ad --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/variable-missing-error-modal.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; + +interface Props { + title: string; + children: React.ReactNode; + isOpen: boolean; + onOk: () => void; + okText?: string; + onCancel: () => void; + cancelText?: string; + isDismissable?: boolean; +} + +export const VariableMissingErrorModal = ({ isOpen, title, cancelText, onCancel, okText, children, onOk, isDismissable = false }: Props) => { + return ( + { + !isOpen && onCancel?.(); + }} + className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-start justify-center bg-black/30" + > + { + !isOpen && onCancel?.(); + }} + isDismissable={isDismissable} + className="flex flex-col max-w-4xl w-full rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] m-[--padding-lg] max-h-full bg-[--color-bg] text-[--color-font]" + > + + <> + + {title} + +
+ {children} +
+
+ + +
+ +
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index dcaffcdbabf..34749a24e41 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -1,4 +1,5 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { Button } from 'react-aria-components'; import { useFetcher, useParams, useRouteLoaderData, useSearchParams } from 'react-router-dom'; import { useInterval } from 'react-use'; import styled from 'styled-components'; @@ -21,6 +22,7 @@ import { MethodDropdown } from './dropdowns/method-dropdown'; import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from './keydown-binder'; import { GenerateCodeModal } from './modals/generate-code-modal'; import { showAlert, showModal, showPrompt } from './modals/index'; +import { VariableMissingErrorModal } from './modals/variable-missing-error-modal'; const StyledDropdownButton = styled(DropdownButton)({ '&:hover:not(:disabled)': { @@ -51,18 +53,25 @@ export const RequestUrlBar = forwardRef(({ onPaste, }, ref) => { const [searchParams, setSearchParams] = useSearchParams(); + const [showEnvVariableMissingModal, setShowEnvVariableMissingModal] = useState(false); + const [missingKey, setMissingKey] = useState(''); if (searchParams.has('error')) { - showAlert({ - title: 'Unexpected Request Failure', - message: ( -
-

The request failed due to an unhandled error:

- -
{searchParams.get('error')}
-
-
- ), - }); + if (searchParams.has('envVariableMissing') && searchParams.get('missingKey')) { + setShowEnvVariableMissingModal(true); + setMissingKey(searchParams.get('missingKey')!); + } else { + showAlert({ + title: 'Unexpected Request Failure', + message: ( +
+

The request failed due to an unhandled error:

+ +
{searchParams.get('error')}
+
+
+ ), + }); + } // clean up params searchParams.delete('error'); @@ -118,7 +127,7 @@ export const RequestUrlBar = forwardRef(({ }); }, [fetcher, organizationId, projectId, requestId, workspaceId]); - const sendOrConnect = useCallback(async (shouldPromptForPathAfterResponse?: boolean) => { + const sendOrConnect = useCallback(async (shouldPromptForPathAfterResponse?: boolean, ignoreUndefinedEnvVariable?: boolean) => { models.stats.incrementExecutedRequests(); window.main.trackSegmentEvent({ event: SegmentEvent.requestExecute, @@ -162,7 +171,7 @@ export const RequestUrlBar = forwardRef(({ } try { - send({ requestId, shouldPromptForPathAfterResponse }); + send({ requestId, shouldPromptForPathAfterResponse, ignoreUndefinedEnvVariable }); } catch (err) { showAlert({ title: 'Unexpected Request Failure', @@ -369,6 +378,22 @@ export const RequestUrlBar = forwardRef(({ )} + { + setShowEnvVariableMissingModal(false); + sendOrConnect(false, true); + }} + onCancel={() => setShowEnvVariableMissingModal(false)} + > +

+ The environment variable + + has been defined but has no value defined on a currently Active Environment +

+
); }); diff --git a/packages/insomnia/src/ui/routes/request.tsx b/packages/insomnia/src/ui/routes/request.tsx index cb017d3f008..dde7fa05e91 100644 --- a/packages/insomnia/src/ui/routes/request.tsx +++ b/packages/insomnia/src/ui/routes/request.tsx @@ -27,6 +27,7 @@ import { isWebSocketRequest, isWebSocketRequestId, WebSocketRequest } from '../. import { WebSocketResponse } from '../../models/websocket-response'; import { getAuthHeader } from '../../network/authentication'; import { fetchRequestData, responseTransform, sendCurlAndWriteTimeline, tryToExecutePreRequestScript, tryToInterpolateRequest, tryToTransformRequestWithPlugins } from '../../network/network'; +import { RenderErrorSubType } from '../../templating'; import { invariant } from '../../utils/invariant'; import { SegmentEvent } from '../analytics'; import { updateMimeType } from '../components/dropdowns/content-type-dropdown'; @@ -354,6 +355,7 @@ const writeToDownloadPath = (downloadPathAndName: string, responsePatch: Respons export interface SendActionParams { requestId: string; shouldPromptForPathAfterResponse?: boolean; + ignoreUndefinedEnvVariable?: boolean; } export const sendAction: ActionFunction = async ({ request, params }) => { @@ -377,7 +379,7 @@ export const sendAction: ActionFunction = async ({ request, params }) => { const cookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId); try { - const { shouldPromptForPathAfterResponse } = await request.json() as SendActionParams; + const { shouldPromptForPathAfterResponse, ignoreUndefinedEnvVariable } = await request.json() as SendActionParams; const mutatedContext = await tryToExecutePreRequestScript( req, environment, @@ -407,6 +409,7 @@ export const sendAction: ActionFunction = async ({ request, params }) => { RENDER_PURPOSE_SEND, undefined, mutatedContext.baseEnvironment, + ignoreUndefinedEnvVariable, ); const renderedRequest = await tryToTransformRequestWithPlugins(renderedResult); @@ -468,6 +471,10 @@ export const sendAction: ActionFunction = async ({ request, params }) => { console.log('Failed to send request', e); const url = new URL(request.url); url.searchParams.set('error', e); + if (e?.extraInfo && e?.extraInfo?.subType === RenderErrorSubType.EnvironmentVariable) { + url.searchParams.set('envVariableMissing', '1'); + url.searchParams.set('missingKey', e?.extraInfo?.missingKey); + } return redirect(`${url.pathname}?${url.searchParams}`); } };