Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Show proper error when environment variables are not valued [INS-3641] #7227

Merged
merged 12 commits into from
Apr 10, 2024
8 changes: 7 additions & 1 deletion packages/insomnia/src/common/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export async function render<T>(
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);
Expand All @@ -244,7 +245,7 @@ export async function render<T>(
} 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:
Expand Down Expand Up @@ -301,6 +302,7 @@ interface BaseRenderContextOptions {
baseEnvironment?: Environment;
purpose?: RenderPurpose;
extraInfo?: ExtraRenderInfo;
ignoreUndefinedEnvVariable?: boolean;
}

interface RenderContextOptions extends BaseRenderContextOptions, Partial<RenderRequest<Request | GrpcRequest | WebSocketRequest>> {
Expand Down Expand Up @@ -471,6 +473,7 @@ export async function getRenderedRequestAndContext(
baseEnvironment,
extraInfo,
purpose,
ignoreUndefinedEnvVariable,
}: RenderRequestOptions,
): Promise<RequestAndContext> {
const ancestors = await getRenderContextAncestors(request);
Expand Down Expand Up @@ -500,6 +503,9 @@ export async function getRenderedRequestAndContext(
},
renderContext,
request.settingDisableRenderRequestBody ? /^body.*/ : null,
THROW_ON_ERROR,
'',
ignoreUndefinedEnvVariable,
);

const renderedRequest = renderResult._request;
Expand Down
2 changes: 2 additions & 0 deletions packages/insomnia/src/network/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export const tryToInterpolateRequest = async (
purpose?: RenderPurpose,
extraInfo?: ExtraRenderInfo,
baseEnvironment?: Environment,
ignoreUndefinedEnvVariable?: boolean,
) => {
try {
return await getRenderedRequestAndContext({
Expand All @@ -182,6 +183,7 @@ export const tryToInterpolateRequest = async (
baseEnvironment,
purpose,
extraInfo,
ignoreUndefinedEnvVariable,
});
} catch (err) {
if ('type' in err && err.type === 'render') {
Expand Down
11 changes: 11 additions & 0 deletions packages/insomnia/src/templating/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
});
});
51 changes: 35 additions & 16 deletions packages/insomnia/src/templating/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +22,7 @@ export class RenderError extends Error {

type!: string;
reason!: string;
extraInfo?: Record<string, string>;
}

// Some constants
Expand Down Expand Up @@ -49,6 +54,7 @@ export function render(
context?: Record<string, any>;
path?: string;
renderMode?: string;
ignoreUndefinedEnvVariable?: boolean;
} = {},
) {
const hasNunjucksInterpolationSymbols = text.includes('{{') && text.includes('}}');
Expand All @@ -67,7 +73,7 @@ export function render(
return new Promise<string | null>(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) {
Expand All @@ -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);
Expand Down Expand Up @@ -130,17 +143,20 @@ export async function getTagDefinitions() {
}));
}

async function getNunjucks(renderMode: string): Promise<NunjucksEnvironment> {
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<NunjucksEnvironment> {
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;
}
}

// ~~~~~~~~~~~~ //
Expand All @@ -149,7 +165,7 @@ async function getNunjucks(renderMode: string): Promise<NunjucksEnvironment> {
const config = {
autoescape: false,
// Don't escape HTML
throwOnUndefined: true,
throwOnUndefined,
// Strict mode
tags: {
blockStart: '{%',
Expand Down Expand Up @@ -194,8 +210,11 @@ async function getNunjucks(renderMode: string): Promise<NunjucksEnvironment> {
}

// ~~~~~~~~~~~~~~~~~~~~ //
// 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) {
Expand Down
9 changes: 9 additions & 0 deletions packages/insomnia/src/templating/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,12 @@ export function decodeEncoding<T>(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] || '';
}
Original file line number Diff line number Diff line change
@@ -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 (
<ModalOverlay
isOpen={isOpen}
onOpenChange={isOpen => {
!isOpen && onCancel?.();
}}
className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-start justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!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]"
>
<Dialog className="outline-none flex-1 h-full flex flex-col gap-4 overflow-hidden">
<>
<Heading
slot="title"
className="text-2xl"
>
{title}
</Heading>
<div className=''>
{children}
</div>
<div className="flex flex-shrink-0 flex-1 justify-end gap-2 items-center">
<Button
className="hover:no-underline flex items-center gap-2 hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
onPress={onCancel}
>
{cancelText || 'Cancel'}
</Button>
<Button
className="hover:no-underline flex items-center gap-2 bg-[--color-surprise] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font-surprise] transition-colors rounded-sm"
onPress={onOk}
>
{okText || 'OK'}
</Button>
</div>
</>
</Dialog>
</Modal>
</ModalOverlay>
);
};
51 changes: 38 additions & 13 deletions packages/insomnia/src/ui/components/request-url-bar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)': {
Expand Down Expand Up @@ -51,18 +53,25 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
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: (
<div>
<p>The request failed due to an unhandled error:</p>
<code className="wide selectable">
<pre>{searchParams.get('error')}</pre>
</code>
</div>
),
});
if (searchParams.has('envVariableMissing') && searchParams.get('missingKey')) {
setShowEnvVariableMissingModal(true);
setMissingKey(searchParams.get('missingKey')!);
} else {
showAlert({
title: 'Unexpected Request Failure',
message: (
<div>
<p>The request failed due to an unhandled error:</p>
<code className="wide selectable">
<pre>{searchParams.get('error')}</pre>
</code>
</div>
),
});
}

// clean up params
searchParams.delete('error');
Expand Down Expand Up @@ -118,7 +127,7 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
});
}, [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,
Expand Down Expand Up @@ -162,7 +171,7 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
}

try {
send({ requestId, shouldPromptForPathAfterResponse });
send({ requestId, shouldPromptForPathAfterResponse, ignoreUndefinedEnvVariable });
} catch (err) {
showAlert({
title: 'Unexpected Request Failure',
Expand Down Expand Up @@ -369,6 +378,22 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
)}
</div>
</div>
<VariableMissingErrorModal
isOpen={showEnvVariableMissingModal}
title="An environment variable is missing"
okText='Execute anyways'
onOk={() => {
setShowEnvVariableMissingModal(false);
sendOrConnect(false, true);
}}
onCancel={() => setShowEnvVariableMissingModal(false)}
>
<p>
The environment variable
<Button className="bg-[--color-surprise] text-[--color-font-surprise] px-3 mx-3 rounded-sm">{missingKey}</Button>
has been defined but has no value defined on a currently Active Environment
</p>
</VariableMissingErrorModal>
</div>
);
});
Expand Down
9 changes: 8 additions & 1 deletion packages/insomnia/src/ui/routes/request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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,
Expand Down Expand Up @@ -407,6 +409,7 @@ export const sendAction: ActionFunction = async ({ request, params }) => {
RENDER_PURPOSE_SEND,
undefined,
mutatedContext.baseEnvironment,
ignoreUndefinedEnvVariable,
);
const renderedRequest = await tryToTransformRequestWithPlugins(renderedResult);

Expand Down Expand Up @@ -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}`);
}
};
Expand Down