feat(#122): supporting process.env vars in UI and electron layer

This commit is contained in:
Anoop M D 2023-09-23 02:55:54 +05:30
parent c91fef2264
commit e3ce420216
22 changed files with 367 additions and 56 deletions

View File

@ -5,10 +5,20 @@ const Wrapper = styled.div`
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
&:nth-child(1) {
width: 30%;
}
&:nth-child(3) {
width: 70px;
}
}
thead {
@ -16,7 +26,7 @@ const Wrapper = styled.div`
font-size: 0.8125rem;
user-select: none;
}
td {
thead td {
padding: 6px 10px;
}
}

View File

@ -2,13 +2,16 @@ import React, { useReducer } from 'react';
import toast from 'react-hot-toast';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import reducer from './reducer';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
const EnvironmentVariables = ({ environment, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [state, reducerDispatch] = useReducer(reducer, { hasChanges: false, variables: environment.variables || [] });
const { variables, hasChanges } = state;
@ -86,15 +89,11 @@ const EnvironmentVariables = ({ environment, collection }) => {
/>
</td>
<td>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
<SingleLineEditor
value={variable.value}
className="mousetrap"
onChange={(e) => handleVarChange(e, variable, 'value')}
theme={storedTheme}
onChange={(newValue) => handleVarChange({ target: { value: newValue } }, variable, 'value')}
collection={collection}
/>
</td>
<td>

View File

@ -19,7 +19,7 @@ const Wrapper = styled.div`
align-items: flex-start;
justify-content: center;
overflow-y: auto;
z-index: 1003;
z-index: 10;
}
.bruno-modal-card {
@ -28,7 +28,7 @@ const Wrapper = styled.div`
background: var(--color-background-top);
border-radius: var(--border-radius);
position: relative;
z-index: 1003;
z-index: 10;
max-width: calc(100% - var(--spacing-base-unit));
box-shadow: var(--box-shadow-base);
display: flex;

View File

@ -19,6 +19,7 @@ const StyledWrapper = styled.div`
.CodeMirror-scroll {
overflow: hidden !important;
padding-bottom: 50px !important;
}
.CodeMirror-hscrollbar {

View File

@ -31,6 +31,7 @@ class SingleLineEditor extends Component {
brunoVarInfo: {
variables: getAllVariables(this.props.collection)
},
scrollbarStyle: null,
extraKeys: {
Enter: () => {
if (this.props.onRun) {

View File

@ -1,5 +1,6 @@
import React from 'react';
import forOwn from 'lodash/forOwn';
import isObject from 'lodash/isObject';
import cloneDeep from 'lodash/cloneDeep';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
@ -15,6 +16,14 @@ const VariablesTable = ({ variables, collectionVariables }) => {
});
});
const getValueToDisplay = (value) => {
if (value === undefined) {
return '';
}
return isObject(value) ? JSON.stringify(value) : value;
};
return (
<StyledWrapper>
<div className="flex flex-col w-full">
@ -24,7 +33,9 @@ const VariablesTable = ({ variables, collectionVariables }) => {
return (
<div key={variable.uid} className="flex">
<div className="variable-name text-yellow-600 text-right pr-2">{variable.name}</div>
<div className="variable-value pl-2 whitespace-normal text-left flex-grow">{variable.value}</div>
<div className="variable-value pl-2 whitespace-normal text-left flex-grow">
{getValueToDisplay(variable.value)}
</div>
</div>
);
})
@ -38,7 +49,9 @@ const VariablesTable = ({ variables, collectionVariables }) => {
return (
<div key={variable.uid} className="flex">
<div className="variable-name text-yellow-600 text-right pr-2">{variable.name}</div>
<div className="variable-value pl-2 whitespace-normal text-left flex-grow">{variable.value}</div>
<div className="variable-value pl-2 whitespace-normal text-left flex-grow">
{getValueToDisplay(variable.value)}
</div>
</div>
);
})

View File

@ -8,6 +8,7 @@ import {
collectionUnlinkDirectoryEvent,
collectionUnlinkEnvFileEvent,
scriptEnvironmentUpdateEvent,
processEnvUpdateEvent,
collectionRenamedEvent,
runRequestEvent,
runFolderEvent
@ -97,6 +98,10 @@ const useCollectionTreeSync = () => {
dispatch(scriptEnvironmentUpdateEvent(val));
};
const _processEnvUpdate = (val) => {
dispatch(processEnvUpdateEvent(val));
};
const _collectionRenamed = (val) => {
dispatch(collectionRenamedEvent(val));
};
@ -119,7 +124,8 @@ const useCollectionTreeSync = () => {
const removeListener6 = ipcRenderer.on('main:collection-renamed', _collectionRenamed);
const removeListener7 = ipcRenderer.on('main:run-folder-event', _runFolderEvent);
const removeListener8 = ipcRenderer.on('main:run-request-event', _runRequestEvent);
const removeListener9 = ipcRenderer.on('main:console-log', (val) => {
const removeListener9 = ipcRenderer.on('main:process-env-update', _processEnvUpdate);
const removeListener10 = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
});
@ -133,6 +139,7 @@ const useCollectionTreeSync = () => {
removeListener7();
removeListener8();
removeListener9();
removeListener10();
};
}, [isElectron]);
};

View File

@ -177,6 +177,14 @@ export const collectionsSlice = createSlice({
collection.collectionVariables = collectionVariables;
}
},
processEnvUpdateEvent: (state, action) => {
const { collectionUid, processEnvVariables } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.processEnvVariables = processEnvVariables;
}
},
requestCancelled: (state, action) => {
const { itemUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@ -1158,6 +1166,7 @@ export const {
renameItem,
cloneItem,
scriptEnvironmentUpdateEvent,
processEnvUpdateEvent,
requestCancelled,
responseReceived,
saveRequest,

View File

@ -1 +1 @@
@import "buttons";
@import 'buttons';

View File

@ -1,4 +1,3 @@
:root {
--color-brand: #546de5;
--color-text: rgb(52 52 52);
@ -21,7 +20,8 @@
--color-method-head: rgb(52 52 52);
}
html, body {
html,
body {
margin: 0;
padding: 0;
font-size: 1rem;
@ -38,15 +38,18 @@ body {
font-size: 0.875rem;
}
body::-webkit-scrollbar, .CodeMirror-vscrollbar::-webkit-scrollbar {
body::-webkit-scrollbar,
.CodeMirror-vscrollbar::-webkit-scrollbar {
width: 0.6rem;
}
body::-webkit-scrollbar-track, .CodeMirror-vscrollbar::-webkit-scrollbar-track {
body::-webkit-scrollbar-track,
.CodeMirror-vscrollbar::-webkit-scrollbar-track {
background-color: #f1f1f1;
}
body::-webkit-scrollbar-thumb, .CodeMirror-vscrollbar::-webkit-scrollbar-thumb {
body::-webkit-scrollbar-thumb,
.CodeMirror-vscrollbar::-webkit-scrollbar-thumb {
background-color: #cdcdcd;
border-radius: 5rem;
}

View File

@ -8,6 +8,7 @@
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@ -20,7 +21,7 @@ if (!SERVER_RENDERED) {
// str is of format {{variableName}}, extract variableName
// we are seeing that from the gql query editor, the token string is of format variableName
const variableName = str.replace('{{', '').replace('}}', '').trim();
const variableValue = options.variables[variableName];
const variableValue = get(options.variables, variableName);
const into = document.createElement('div');
const descriptionDiv = document.createElement('div');

View File

@ -542,6 +542,11 @@ export const getAllVariables = (collection) => {
return {
...environmentVariables,
...collection.collectionVariables
...collection.collectionVariables,
process: {
env: {
...collection.processEnvVariables
}
}
};
};

View File

@ -1,3 +1,6 @@
import get from 'lodash/get';
import isString from 'lodash/isString';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
@ -5,6 +8,11 @@ if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
const pathFoundInVariables = (path, obj) => {
const value = get(obj, path);
return isString(value);
};
export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
let variablesOverlay = {
@ -15,7 +23,8 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
while ((ch = stream.next()) != null) {
if (ch == '}' && stream.next() == '}') {
stream.eat('}');
if (word in variables) {
let found = pathFoundInVariables(word, variables);
if (found) {
return 'variable-valid';
} else {
return 'variable-invalid';

View File

@ -27,6 +27,7 @@
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",
"handlebars": "^4.7.8",
"is-valid-path": "^0.1.1",
"lodash": "^4.17.21",
"mustache": "^4.2.0",

View File

@ -4,11 +4,13 @@ const path = require('path');
const chokidar = require('chokidar');
const { hasJsonExtension, hasBruExtension, writeFile } = require('../utils/filesystem');
const { bruToEnvJson, envJsonToBru, bruToJson, jsonToBru } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const { isLegacyEnvFile, migrateLegacyEnvFile, isLegacyBruFile, migrateLegacyBruFile } = require('../bru/migrate');
const { itemSchema } = require('@usebruno/schema');
const { uuid } = require('../utils/common');
const { getRequestUid } = require('../cache/requestUids');
const { setDotEnvVars } = require('../store/process-env');
const isJsonEnvironmentConfig = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
@ -17,6 +19,13 @@ const isJsonEnvironmentConfig = (pathname, collectionPath) => {
return dirname === collectionPath && basename === 'environments.json';
};
const isDotEnvFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === '.env';
};
const isBruEnvironmentConfig = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const envDirectory = path.join(collectionPath, 'environments');
@ -125,6 +134,25 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
const add = async (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher add: ${pathname}`);
if (isDotEnvFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const jsonData = dotenvToJson(content);
setDotEnvVars(collectionUid, jsonData);
const payload = {
collectionUid,
processEnvVariables: {
...process.env,
...jsonData
}
};
win.webContents.send('main:process-env-update', payload);
} catch (err) {
console.error(err);
}
}
if (isJsonEnvironmentConfig(pathname, collectionPath)) {
try {
const dirname = path.dirname(pathname);
@ -220,6 +248,25 @@ const addDirectory = (win, pathname, collectionUid, collectionPath) => {
};
const change = async (win, pathname, collectionUid, collectionPath) => {
if (isDotEnvFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const jsonData = dotenvToJson(content);
setDotEnvVars(collectionUid, jsonData);
const payload = {
collectionUid,
processEnvVariables: {
...process.env,
...jsonData
}
};
win.webContents.send('main:process-env-update', payload);
} catch (err) {
console.error(err);
}
}
if (isBruEnvironmentConfig(pathname, collectionPath)) {
return changeEnvironmentFile(win, pathname, collectionUid);
}

View File

@ -13,6 +13,7 @@ const { uuid } = require('../../utils/common');
const interpolateVars = require('./interpolate-vars');
const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
const { getPreferences } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
// override the default escape function to prevent escaping
Mustache.escape = function (value) {
@ -129,6 +130,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath
);
if (result) {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
@ -136,6 +138,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
}
}
// run pre-request script
const requestScript = get(request, 'script.req');
@ -158,7 +161,9 @@ const registerNetworkIpc = (mainWindow) => {
});
}
interpolateVars(request, envVars, collectionVariables);
const processEnvVars = getProcessEnvVars(collectionUid);
interpolateVars(request, envVars, collectionVariables, processEnvVars);
// stringify the request url encoded params
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
@ -222,6 +227,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath
);
if (result) {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
@ -229,6 +235,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
}
}
// run post-response script
const responseScript = get(request, 'script.res');
@ -520,7 +527,21 @@ const registerNetworkIpc = (mainWindow) => {
const preRequestVars = get(request, 'vars.req', []);
if (preRequestVars && preRequestVars.length) {
const varsRuntime = new VarsRuntime();
varsRuntime.runPreRequestVars(preRequestVars, request, envVars, collectionVariables, collectionPath);
const result = varsRuntime.runPreRequestVars(
preRequestVars,
request,
envVars,
collectionVariables,
collectionPath
);
if (result) {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
}
// run pre-request script
@ -543,8 +564,10 @@ const registerNetworkIpc = (mainWindow) => {
});
}
const processEnvVars = getProcessEnvVars(collectionUid);
// interpolate variables inside request
interpolateVars(request, envVars, collectionVariables);
interpolateVars(request, envVars, collectionVariables, processEnvVars);
// todo:
// i have no clue why electron can't send the request object
@ -587,12 +610,14 @@ const registerNetworkIpc = (mainWindow) => {
collectionPath
);
if (result) {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
collectionVariables: result.collectionVariables,
collectionUid
});
}
}
// run response script
const responseScript = get(request, 'script.res');

View File

@ -1,24 +1,51 @@
const Mustache = require('mustache');
const { each, get, forOwn } = require('lodash');
const Handlebars = require('handlebars');
const { each, forOwn, cloneDeep } = require('lodash');
// override the default escape function to prevent escaping
Mustache.escape = function (value) {
return value;
const interpolateEnvVars = (str, processEnvVars) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
const template = Handlebars.compile(str, { noEscape: true });
return template({
process: {
env: {
...processEnvVars
}
}
});
};
const interpolateVars = (request, envVars = {}, collectionVariables = {}) => {
const interpolateVars = (request, envVars = {}, collectionVariables = {}, processEnvVars = {}) => {
// we clone envVars because we don't want to modify the original object
envVars = cloneDeep(envVars);
// envVars can inturn have values as {{process.env.VAR_NAME}}
// so we need to interpolate envVars first with processEnvVars
forOwn(envVars, (value, key) => {
envVars[key] = interpolateEnvVars(value, processEnvVars);
});
const interpolate = (str) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
const template = Handlebars.compile(str, { noEscape: true });
// collectionVariables take precedence over envVars
const combinedVars = {
...envVars,
...collectionVariables
...collectionVariables,
process: {
env: {
...processEnvVars
}
}
};
return Mustache.render(str, combinedVars);
return template(combinedVars);
};
request.url = interpolate(request.url);

View File

@ -0,0 +1,37 @@
/**
* This file stores all the process.env variables under collection scope
*
* process.env variables are sourced from 2 places:
* 1. .env file in the root of the project
* 2. process.env variables set in the OS
*
* Multiple collections can be opened in the same electron app.
* Each collection's .env file can have different values for the same process.env variable.
*/
const dotEnvVars = {};
// collectionUid is a hash based on the collection path)
const getProcessEnvVars = (collectionUid) => {
// if there are no .env vars for this collection, return the process.env
if (!dotEnvVars[collectionUid]) {
return {
...process.env
};
}
// if there are .env vars for this collection, return the process.env merged with the .env vars
return {
...process.env,
...dotEnvVars[collectionUid]
};
};
const setDotEnvVars = (collectionUid, envVars) => {
dotEnvVars[collectionUid] = envVars;
};
module.exports = {
getProcessEnvVars,
setDotEnvVars
};

View File

@ -41,10 +41,39 @@ const generateUidBasedOnHash = (str) => {
return `${hash}`.padEnd(21, '0');
};
const flattenDataForDotNotation = (data) => {
var result = {};
function recurse(current, prop) {
if (Object(current) !== current) {
result[prop] = current;
} else if (Array.isArray(current)) {
for (var i = 0, l = current.length; i < l; i++) {
recurse(current[i], prop + '[' + i + ']');
}
if (l == 0) {
result[prop] = [];
}
} else {
var isEmpty = true;
for (var p in current) {
isEmpty = false;
recurse(current[p], prop ? prop + '.' + p : p);
}
if (isEmpty && prop) {
result[prop] = {};
}
}
}
recurse(data, '');
return result;
};
module.exports = {
uuid,
stringifyJson,
parseJson,
simpleHash,
generateUidBasedOnHash
generateUidBasedOnHash,
flattenDataForDotNotation
};

View File

@ -0,0 +1,85 @@
const { flattenDataForDotNotation } = require('../../src/utils/common');
describe('utils: flattenDataForDotNotation', () => {
test('Flatten a simple object with dot notation', () => {
const input = {
person: {
name: 'John',
age: 30,
},
};
const expectedOutput = {
'person.name': 'John',
'person.age': 30,
};
expect(flattenDataForDotNotation(input)).toEqual(expectedOutput);
});
test('Flatten an object with nested arrays', () => {
const input = {
users: [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 28 },
],
};
const expectedOutput = {
'users[0].name': 'Alice',
'users[0].age': 25,
'users[1].name': 'Bob',
'users[1].age': 28,
};
expect(flattenDataForDotNotation(input)).toEqual(expectedOutput);
});
test('Flatten an empty object', () => {
const input = {};
const expectedOutput = {};
expect(flattenDataForDotNotation(input)).toEqual(expectedOutput);
});
test('Flatten an object with nested objects', () => {
const input = {
person: {
name: 'Alice',
address: {
city: 'New York',
zipcode: '10001',
},
},
};
const expectedOutput = {
'person.name': 'Alice',
'person.address.city': 'New York',
'person.address.zipcode': '10001',
};
expect(flattenDataForDotNotation(input)).toEqual(expectedOutput);
});
test('Flatten an object with arrays of objects', () => {
const input = {
teams: [
{ name: 'Team A', members: ['Alice', 'Bob'] },
{ name: 'Team B', members: ['Charlie', 'David'] },
],
};
const expectedOutput = {
'teams[0].name': 'Team A',
'teams[0].members[0]': 'Alice',
'teams[0].members[1]': 'Bob',
'teams[1].name': 'Team B',
'teams[1].members[0]': 'Charlie',
'teams[1].members[1]': 'David',
};
expect(flattenDataForDotNotation(input)).toEqual(expectedOutput);
});
});

View File

@ -4,6 +4,7 @@ const bruToJsonV2 = require('../v2/src/bruToJson');
const jsonToBruV2 = require('../v2/src/jsonToBru');
const bruToEnvJsonV2 = require('../v2/src/envToJson');
const envJsonToBruV2 = require('../v2/src/jsonToEnv');
const dotenvToJson = require('../v2/src/dotenvToJson');
module.exports = {
bruToJson,
@ -14,5 +15,7 @@ module.exports = {
bruToJsonV2,
jsonToBruV2,
bruToEnvJsonV2,
envJsonToBruV2
envJsonToBruV2,
dotenvToJson
};