From de2fc0ba42414978dd90f405b34e3660df340823 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 22 Jan 2020 14:23:19 -0500 Subject: [PATCH] Scope responses to active environment (#1905) * Scope responses to active environment * Update response history to support scoped envs --- .../app/common/__tests__/har.test.js | 54 +++++------ packages/insomnia-app/app/common/har.js | 5 +- packages/insomnia-app/app/models/response.js | 92 ++++++++++++++++--- packages/insomnia-app/app/network/network.js | 72 ++++++++++----- .../app/plugins/context/network.js | 2 +- .../dropdowns/response-history-dropdown.js | 40 +++++--- .../app/ui/components/response-pane.js | 4 + .../insomnia-app/app/ui/components/wrapper.js | 15 +-- .../insomnia-app/app/ui/containers/app.js | 4 +- .../insomnia-app/app/ui/redux/selectors.js | 13 ++- .../__tests__/index.test.js | 3 + plugins/insomnia-plugin-response/index.js | 20 +++- 12 files changed, 230 insertions(+), 94 deletions(-) diff --git a/packages/insomnia-app/app/common/__tests__/har.test.js b/packages/insomnia-app/app/common/__tests__/har.test.js index 68593b6e92..d27e410d91 100644 --- a/packages/insomnia-app/app/common/__tests__/har.test.js +++ b/packages/insomnia-app/app/common/__tests__/har.test.js @@ -137,6 +137,30 @@ describe('exportHar()', () => { req3.headers.push({ name: 'X-Request', value: '3' }); await models.request.create(req3); + const envBase = await models.environment.getOrCreateForWorkspace(workspace); + await models.environment.update(envBase, { + data: { + envvalue: '', + }, + }); + const envPublic = await models.environment.create({ + _id: 'env_1', + name: 'Public', + parentId: envBase._id, + data: { + envvalue: 'public', + }, + }); + const envPrivate = await models.environment.create({ + _id: 'env_2', + name: 'Private', + isPrivate: true, + parentId: envBase._id, + data: { + envvalue: 'private', + }, + }); + await models.response.create({ _id: 'res_1', parentId: req1._id, @@ -153,36 +177,8 @@ describe('exportHar()', () => { statusCode: 500, }); - const envBase = await models.environment.getOrCreateForWorkspace(workspace); - await models.environment.update(envBase, { - data: { - envvalue: '', - }, - }); - const envPublic = await models.environment.create({ - _id: 'env_1', - name: 'Public', - parentId: envBase._id, - }); - await models.environment.update(envPublic, { - data: { - envvalue: 'public', - }, - }); - const envPrivate = await models.environment.create({ - _id: 'env_2', - name: 'Private', - isPrivate: true, - parentId: envBase._id, - }); - await models.environment.update(envPrivate, { - data: { - envvalue: 'private', - }, - }); - const exportRequests = [ - { requestId: req1._id, environmentId: 'n/a' }, + { requestId: req1._id, environmentId: null }, { requestId: req2._id, environmentId: envPublic._id }, { requestId: req3._id, environmentId: envPrivate._id }, ]; diff --git a/packages/insomnia-app/app/common/har.js b/packages/insomnia-app/app/common/har.js index 39bd1dcf3b..79d7c99c75 100644 --- a/packages/insomnia-app/app/common/har.js +++ b/packages/insomnia-app/app/common/har.js @@ -171,7 +171,7 @@ export type Har = { export type ExportRequest = { requestId: string, - environmentId: string, + environmentId: string | null, }; export async function exportHar(exportRequests: Array): Promise { @@ -191,6 +191,7 @@ export async function exportHar(exportRequests: Array): Promise { try { diff --git a/packages/insomnia-app/app/models/response.js b/packages/insomnia-app/app/models/response.js index a1e6d287d2..c4ea87502c 100644 --- a/packages/insomnia-app/app/models/response.js +++ b/packages/insomnia-app/app/models/response.js @@ -28,6 +28,7 @@ export type ResponseTimelineEntry = { }; type BaseResponse = { + environmentId: string | null, statusCode: number, statusMessage: string, httpVersion: string, @@ -52,6 +53,7 @@ export type Response = BaseModel & BaseResponse; export function init(): BaseResponse { return { + environmentId: '__LEGACY__', statusCode: 0, statusMessage: '', httpVersion: '', @@ -80,10 +82,14 @@ export async function migrate(doc: Object) { return doc; } -export async function hookDatabaseInit() { - await models.response.cleanDeletedResponses(); - +export function hookDatabaseInit() { console.log('Init responses DB'); + process.nextTick(async () => { + await models.response.cleanDeletedResponses(); + + // Can remove this after March 2020 + await addLegacyEnvironmentId(); + }); } export function hookRemove(doc: Response) { @@ -103,24 +109,57 @@ export async function all(): Promise> { return db.all(type); } -export async function removeForRequest(parentId: string) { - await db.removeWhere(type, { parentId }); +export async function removeForRequest(parentId: string, environmentId?: string | null) { + const q: Object = { + parentId, + }; + + // Only add if not undefined. null is not the same as undefined + // null: find responses sent from base environment + // undefined: find all responses + if (environmentId !== undefined) { + q.environmentId = environmentId; + } + + // Also delete legacy responses here or else the user will be confused as to + // why some responses are still showing in the UI. + await db.removeWhere(type, { $or: [q, { parentId, environmentId: '__LEGACY__' }] }); } export function remove(response: Response) { return db.remove(response); } -export async function findRecentForRequest( +async function _findRecentForRequest( requestId: string, + environmentId: string | null, limit: number, ): Promise> { - const responses = await db.findMostRecentlyModified(type, { parentId: requestId }, limit); - return responses; + const q = { + parentId: requestId, + environmentId: environmentId, + }; + + return db.findMostRecentlyModified( + type, + { + $or: [ + q, + { + parentId: requestId, + environmentId: '__LEGACY__', + }, + ], + }, + limit, + ); } -export async function getLatestForRequest(requestId: string): Promise { - const responses = await findRecentForRequest(requestId, 1); +export async function getLatestForRequest( + requestId: string, + environmentId: string | null, +): Promise { + const responses = await _findRecentForRequest(requestId, environmentId, 1); const response = (responses[0]: ?Response); return response || null; } @@ -283,6 +322,33 @@ async function migrateTimelineToFileSystem(doc: Object) { } } +async function addLegacyEnvironmentId() { + const all = await db.all(type); + + // Bail early if none yet. This is mostly here so tests don't + // get impacted + if (all.length === 0) { + return; + } + + // We need to query on the __LEGACY__ value so we need + // to migrate it into the DB to make that possible. If we + // don't do that, it will exist on queried document by default + // but won't actually be in the DB. + const promises = []; + const flushId = await db.bufferChanges(); + for (const doc of all) { + if (doc.environmentId !== '__LEGACY__') { + continue; + } + + promises.push(db.docUpdate(doc)); + } + + await Promise.all(promises); + await db.flushChanges(flushId); +} + export async function cleanDeletedResponses() { const responsesDir = path.join(getDataDirectory(), 'responses'); mkdirp.sync(responsesDir); @@ -303,6 +369,10 @@ export async function cleanDeletedResponses() { continue; } - fs.unlinkSync(path.join(responsesDir, filePath)); + try { + fs.unlinkSync(path.join(responsesDir, filePath)); + } catch (err) { + // Just keep going, doesn't matter + } } } diff --git a/packages/insomnia-app/app/network/network.js b/packages/insomnia-app/app/network/network.js index 50a170370a..8cc6f89b8e 100644 --- a/packages/insomnia-app/app/network/network.js +++ b/packages/insomnia-app/app/network/network.js @@ -60,25 +60,27 @@ import { cookiesFromJar, jarFromCookies } from 'insomnia-cookies'; import { urlMatchesCertHost } from './url-matches-cert-host'; import aws4 from 'aws4'; import { buildMultipart } from './multipart'; +import type { Environment } from '../models/environment'; export type ResponsePatch = {| - statusMessage?: string, - error?: string, - url?: string, - statusCode?: number, - bytesContent?: number, - bodyPath?: string, bodyCompression?: 'zip' | null, - message?: string, - httpVersion?: string, - headers?: Array, - elapsedTime?: number, - contentType?: string, + bodyPath?: string, + bytesContent?: number, bytesRead?: number, + contentType?: string, + elapsedTime?: number, + environmentId?: string | null, + error?: string, + headers?: Array, + httpVersion?: string, + message?: string, parentId?: string, - settingStoreCookies?: boolean, settingSendCookies?: boolean, + settingStoreCookies?: boolean, + statusCode?: number, + statusMessage?: string, timelinePath?: string, + url?: string, |}; // Time since user's last keypress to wait before making the request @@ -101,6 +103,7 @@ export async function _actuallySend( renderContext: Object, workspace: Workspace, settings: Settings, + environment: Environment | null, ): Promise { return new Promise(async resolve => { let timeline: Array = []; @@ -124,9 +127,11 @@ export async function _actuallySend( ): Promise { const timelinePath = await storeTimeline(timeline); + const environmentId = environment ? environment._id : null; const responsePatchBeforeHooks = Object.assign( ({ timelinePath, + environmentId, parentId: renderedRequest._id, bodyCompression: null, // Will default to .zip otherwise bodyPath: bodyPath || '', @@ -719,14 +724,14 @@ export async function _actuallySend( // Return the response data const responsePatch = { - headers, contentType, - statusCode, + headers, httpVersion, + statusCode, statusMessage, - elapsedTime: curl.getInfo(Curl.info.TOTAL_TIME) * 1000, - bytesRead: curl.getInfo(Curl.info.SIZE_DOWNLOAD), bytesContent: responseBodyBytes, + bytesRead: curl.getInfo(Curl.info.SIZE_DOWNLOAD), + elapsedTime: curl.getInfo(Curl.info.TOTAL_TIME) * 1000, url: curl.getInfo(Curl.info.EFFECTIVE_URL), }; @@ -749,7 +754,14 @@ export async function _actuallySend( statusMessage = 'Abort'; } - respond({ statusMessage, error }, null, true); + respond( + { + statusMessage, + error, + }, + null, + true, + ); }); curl.perform(); @@ -790,6 +802,8 @@ export async function sendWithSettings( parentId: request._id, }); + const environment: Environment | null = await models.environment.getById(environmentId || 'n/a'); + let renderResult: { request: RenderedRequest, context: Object }; try { renderResult = await getRenderedRequestAndContext(newRequest, environmentId); @@ -797,12 +811,18 @@ export async function sendWithSettings( throw new Error(`Failed to render request: ${requestId}`); } - return _actuallySend(renderResult.request, renderResult.context, workspace, settings); + return _actuallySend( + renderResult.request, + renderResult.context, + workspace, + settings, + environment, + ); } export async function send( requestId: string, - environmentId: string | null, + environmentId?: string, extraInfo?: ExtraRenderInfo, ): Promise { console.log(`[network] Sending req=${requestId}`); @@ -834,9 +854,11 @@ export async function send( throw new Error(`Failed to find request to send for ${requestId}`); } + const environment: Environment | null = await models.environment.getById(environmentId || 'n/a'); + const renderResult = await getRenderedRequestAndContext( request, - environmentId, + environmentId || null, RENDER_PURPOSE_SEND, extraInfo, ); @@ -858,13 +880,14 @@ export async function send( ); } catch (err) { return { - url: renderedRequestBeforePlugins.url, - parentId: renderedRequestBeforePlugins._id, + environmentId: environmentId, error: err.message, - statusCode: STATUS_CODE_PLUGIN_ERROR, - statusMessage: err.plugin ? `Plugin ${err.plugin.name}` : 'Plugin', + parentId: renderedRequestBeforePlugins._id, settingSendCookies: renderedRequestBeforePlugins.settingSendCookies, settingStoreCookies: renderedRequestBeforePlugins.settingStoreCookies, + statusCode: STATUS_CODE_PLUGIN_ERROR, + statusMessage: err.plugin ? `Plugin ${err.plugin.name}` : 'Plugin', + url: renderedRequestBeforePlugins.url, }; } @@ -873,6 +896,7 @@ export async function send( renderedContextBeforePlugins, workspace, settings, + environment, ); console.log( diff --git a/packages/insomnia-app/app/plugins/context/network.js b/packages/insomnia-app/app/plugins/context/network.js index 249ef4009c..c4b90c626f 100644 --- a/packages/insomnia-app/app/plugins/context/network.js +++ b/packages/insomnia-app/app/plugins/context/network.js @@ -8,7 +8,7 @@ import type { ExtraRenderInfo } from '../../common/render'; export function init(activeEnvironmentId: string | null): { network: Object } { const network = { async sendRequest(request: Request, extraInfo?: ExtraRenderInfo): Promise { - const responsePatch = await send(request._id, activeEnvironmentId, extraInfo); + const responsePatch = await send(request._id, activeEnvironmentId || undefined, extraInfo); const settings = await models.settings.getOrCreate(); return models.response.create(responsePatch, settings.maxHistoryResponses); }, diff --git a/packages/insomnia-app/app/ui/components/dropdowns/response-history-dropdown.js b/packages/insomnia-app/app/ui/components/dropdowns/response-history-dropdown.js index 08dc61d50d..1ac600c9ce 100644 --- a/packages/insomnia-app/app/ui/components/dropdowns/response-history-dropdown.js +++ b/packages/insomnia-app/app/ui/components/dropdowns/response-history-dropdown.js @@ -15,15 +15,17 @@ import TimeFromNow from '../time-from-now'; import type { Response } from '../../../models/response'; import type { RequestVersion } from '../../../models/request-version'; import { decompressObject } from '../../../common/misc'; +import type { Environment } from '../../../models/environment'; type Props = { handleSetActiveResponse: Response => Promise, - handleDeleteResponses: (requestId: string) => Promise, + handleDeleteResponses: (requestId: string, environmentId: string | null) => Promise, handleDeleteResponse: Response => Promise, requestId: string, responses: Array, requestVersions: Array, activeResponse: Response, + activeEnvironment: ?Environment, }; @autobind @@ -35,7 +37,9 @@ class ResponseHistoryDropdown extends React.PureComponent { } _handleDeleteResponses() { - this.props.handleDeleteResponses(this.props.requestId); + const { requestId, activeEnvironment } = this.props; + const environmentId = activeEnvironment ? activeEnvironment._id : null; + this.props.handleDeleteResponses(requestId, environmentId); } _handleDeleteResponse() { @@ -97,34 +101,39 @@ class ResponseHistoryDropdown extends React.PureComponent { renderPastResponses(responses: Array) { const now = moment(); // Four arrays for four time groups - const categories = [[], [], [], []]; + const categories = { minutes: [], hours: [], today: [], week: [], other: [] }; responses.forEach(r => { - const resTime = moment(r.modified); + const resTime = moment(r.created); if (now.diff(resTime, 'minutes') < 5) { // Five minutes ago - categories[0].push(r); + categories.minutes.push(r); } else if (now.diff(resTime, 'hours') < 2) { // Two hours ago - categories[1].push(r); + categories.hours.push(r); } else if (now.isSame(resTime, 'day')) { // Today - categories[2].push(r); + categories.today.push(r); } else if (now.isSame(resTime, 'week')) { // This week - categories[3].push(r); + categories.week.push(r); + } else { + // Older + categories.other.push(r); } }); return ( 5 Minutes Ago - {categories[0].map(this.renderDropdownItem)} + {categories.minutes.map(this.renderDropdownItem)} 2 Hours Ago - {categories[1].map(this.renderDropdownItem)} + {categories.hours.map(this.renderDropdownItem)} Today - {categories[2].map(this.renderDropdownItem)} + {categories.today.map(this.renderDropdownItem)} This Week - {categories[3].map(this.renderDropdownItem)} + {categories.week.map(this.renderDropdownItem)} + Older Than This Week + {categories.other.map(this.renderDropdownItem)} ); } @@ -136,9 +145,12 @@ class ResponseHistoryDropdown extends React.PureComponent { handleDeleteResponses, // eslint-disable-line no-unused-vars handleDeleteResponse, // eslint-disable-line no-unused-vars responses, + activeEnvironment, ...extraProps } = this.props; + const environmentName = activeEnvironment ? activeEnvironment.name : 'Base'; + const isLatestResponseActive = !responses.length || activeResponse._id === responses[0]._id; return ( @@ -155,7 +167,9 @@ class ResponseHistoryDropdown extends React.PureComponent { )} - Response History + + {environmentName} Responses + Delete Current Response diff --git a/packages/insomnia-app/app/ui/components/response-pane.js b/packages/insomnia-app/app/ui/components/response-pane.js index f2a3be05b6..332b8cea4a 100644 --- a/packages/insomnia-app/app/ui/components/response-pane.js +++ b/packages/insomnia-app/app/ui/components/response-pane.js @@ -30,6 +30,7 @@ import { hotKeyRefs } from '../../common/hotkeys'; import type { RequestVersion } from '../../models/request-version'; import { showError } from '../components/modals/index'; import { json as jsonPrettify } from 'insomnia-prettify'; +import type { Environment } from '../../models/environment'; type Props = { // Functions @@ -59,6 +60,7 @@ type Props = { requestVersions: Array, request: ?Request, response: ?Response, + environment: ?Environment, }; @autobind @@ -183,6 +185,7 @@ class ResponsePane extends React.PureComponent { editorIndentSize, editorKeyMap, editorLineWrapping, + environment, filter, disableResponsePreviewLinks, filterHistory, @@ -289,6 +292,7 @@ class ResponsePane extends React.PureComponent { { showModal(RequestSettingsModal, { request: this.props.activeRequest }); } - async _handleDeleteResponses(): Promise { - if (!this.props.activeRequest) { - console.warn('Tried to delete responses when request not active'); - return; - } + async _handleDeleteResponses(requestId: string, environmentId: string | null): Promise { + const { handleSetActiveResponse, activeRequest } = this.props; - await models.response.removeForRequest(this.props.activeRequest._id); - this._handleSetActiveResponse(null); + await models.response.removeForRequest(requestId, environmentId); + + if (activeRequest && activeRequest._id === requestId) { + await handleSetActiveResponse(requestId, null); + } } async _handleDeleteResponse(response: Response): Promise { @@ -853,6 +853,7 @@ class Wrapper extends React.PureComponent { editorIndentSize={settings.editorIndentSize} editorKeyMap={settings.editorKeyMap} editorLineWrapping={settings.editorLineWrapping} + environment={activeEnvironment} filter={responseFilter} filterHistory={responseFilterHistory} handleDeleteResponse={this._handleDeleteResponse} diff --git a/packages/insomnia-app/app/ui/containers/app.js b/packages/insomnia-app/app/ui/containers/app.js index fe1fd1f97c..695e60400c 100644 --- a/packages/insomnia-app/app/ui/containers/app.js +++ b/packages/insomnia-app/app/ui/containers/app.js @@ -709,6 +709,7 @@ class App extends PureComponent { } async _handleSetActiveResponse(requestId, activeResponse = null) { + const { activeEnvironment } = this.props; const activeResponseId = activeResponse ? activeResponse._id : null; await App._updateRequestMetaByParentId(requestId, { activeResponseId }); @@ -716,7 +717,8 @@ class App extends PureComponent { if (activeResponseId) { response = await models.response.getById(activeResponseId); } else { - response = await models.response.getLatestForRequest(requestId); + const environmentId = activeEnvironment ? activeEnvironment._id : null; + response = await models.response.getLatestForRequest(requestId, environmentId); } const requestVersionId = response ? response.requestVersionId : 'n/a'; diff --git a/packages/insomnia-app/app/ui/redux/selectors.js b/packages/insomnia-app/app/ui/redux/selectors.js index af324a5249..e50ec2ce04 100644 --- a/packages/insomnia-app/app/ui/redux/selectors.js +++ b/packages/insomnia-app/app/ui/redux/selectors.js @@ -269,10 +269,19 @@ export const selectActiveRequestMeta = createSelector( export const selectActiveRequestResponses = createSelector( selectActiveRequest, selectEntitiesLists, - (activeRequest, entities) => { + selectActiveWorkspaceMeta, + (activeRequest, entities, meta) => { const requestId = activeRequest ? activeRequest._id : 'n/a'; return entities.responses - .filter(response => requestId === response.parentId) + .filter(response => { + const requestMatches = requestId === response.parentId; + const environmentMatches = response.environmentId === meta.activeEnvironmentId; + + // Legacy responses were sent before environment scoping existed + const isLegacy = response.environmentId === '__LEGACY__'; + + return requestMatches && (environmentMatches || isLegacy); + }) .sort((a, b) => (a.created > b.created ? -1 : 1)); }, ); diff --git a/plugins/insomnia-plugin-response/__tests__/index.test.js b/plugins/insomnia-plugin-response/__tests__/index.test.js index 6fd52803d9..a7bd4237ac 100644 --- a/plugins/insomnia-plugin-response/__tests__/index.test.js +++ b/plugins/insomnia-plugin-response/__tests__/index.test.js @@ -533,6 +533,9 @@ function _genTestContext(requests, responses, extraInfoRoot) { return { renderPurpose: 'send', context: { + getEnvironmentId() { + return null; + }, getExtraInfo(key) { if (_extraInfo) { return _extraInfo[key] || null; diff --git a/plugins/insomnia-plugin-response/index.js b/plugins/insomnia-plugin-response/index.js index 850db29d0c..39cfdba778 100644 --- a/plugins/insomnia-plugin-response/index.js +++ b/plugins/insomnia-plugin-response/index.js @@ -2,6 +2,10 @@ const jq = require('jsonpath'); const iconv = require('iconv-lite'); const { query: queryXPath } = require('insomnia-xpath'); +function isFilterableField(field) { + return field !== 'raw' && field !== 'url'; +} + module.exports.templateTags = [ { name: 'response', @@ -27,6 +31,11 @@ module.exports.templateTags = [ description: 'value of response header', value: 'header', }, + { + displayName: 'Request URL', + description: 'Url of initiating request', + value: 'url', + }, ], }, { @@ -37,7 +46,7 @@ module.exports.templateTags = [ { type: 'string', encoding: 'base64', - hide: args => args[0].value === 'raw', + hide: args => !isFilterableField(args[0].value), displayName: args => { switch (args[0].value) { case 'body': @@ -77,7 +86,7 @@ module.exports.templateTags = [ filter = filter || ''; resendBehavior = (resendBehavior || 'never').toLowerCase(); - if (!['body', 'header', 'raw'].includes(field)) { + if (!['body', 'header', 'raw', 'url'].includes(field)) { throw new Error(`Invalid response field ${field}`); } @@ -90,7 +99,8 @@ module.exports.templateTags = [ throw new Error(`Could not find request ${id}`); } - let response = await context.util.models.response.getLatestForRequestId(id); + const environmentId = context.context.getEnvironmentId(); + let response = await context.util.models.response.getLatestForRequestId(id, environmentId); let shouldResend = false; if (context.context.getExtraInfo('fromResponseTag')) { @@ -132,7 +142,7 @@ module.exports.templateTags = [ throw new Error('No successful responses for request'); } - if (field !== 'raw' && !filter) { + if (isFilterableField(field) && !filter) { throw new Error(`No ${field} filter specified`); } @@ -140,6 +150,8 @@ module.exports.templateTags = [ if (field === 'header') { return matchHeader(response.headers, sanitizedFilter); + } else if (field === 'url') { + return response.url; } else if (field === 'raw') { const bodyBuffer = context.util.models.response.getBodyBuffer(response, ''); const match = response.contentType.match(/charset=([\w-]+)/);