diff --git a/app/models/index.js b/app/models/index.js index 5d0f83d46a..4b4e6aa4d8 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -7,6 +7,7 @@ import * as _cookieJar from './cookie-jar'; import * as _requestGroup from './request-group'; import * as _requestGroupMeta from './request-group-meta'; import * as _request from './request'; +import * as _requestVersion from './request-version'; import * as _requestMeta from './request-meta'; import * as _response from './response'; import * as _oAuth2Token from './o-auth-2-token'; @@ -21,6 +22,7 @@ export const cookieJar = _cookieJar; export const requestGroup = _requestGroup; export const requestGroupMeta = _requestGroupMeta; export const request = _request; +export const requestVersion = _requestVersion; export const requestMeta = _requestMeta; export const response = _response; export const oAuth2Token = _oAuth2Token; @@ -35,6 +37,7 @@ const _models = { [requestGroup.type]: requestGroup, [requestGroupMeta.type]: requestGroupMeta, [request.type]: request, + [requestVersion.type]: requestVersion, [requestMeta.type]: requestMeta, [response.type]: response, [oAuth2Token.type]: oAuth2Token diff --git a/app/models/request-version.js b/app/models/request-version.js new file mode 100644 index 0000000000..c1d5998479 --- /dev/null +++ b/app/models/request-version.js @@ -0,0 +1,117 @@ +import zlib from 'zlib'; +import deepEqual from 'deep-equal'; +import * as models from './index'; +import * as db from '../common/database'; +export const name = 'Request Version'; +export const type = 'RequestVersion'; +export const prefix = 'rvr'; +export const canDuplicate = false; + +const FIELDS_TO_IGNORE_IN_REQUEST_DIFF = [ + '_id', + 'type', + 'created', + 'modified', + 'metaSortKey', + 'description', + 'name' +]; + +export function init () { + return { + compressedRequest: null + }; +} + +export function migrate (doc) { + return doc; +} + +export function getById (id) { + return db.get(type, id); +} + +export async function create (request) { + if (!request.type === models.request.type) { + throw new Error(`New ${type} was not given a valid ${models.request.type} instance`); + } + + const parentId = request._id; + const latestRequestVersion = await getLatestByParentId(parentId); + const latestRequest = latestRequestVersion + ? await _decompressRequest(latestRequestVersion.compressedRequest) + : null; + + const hasChanged = _diffRequests(latestRequest, request); + if (hasChanged) { + // Create a new version if the request has been modified + const compressedRequest = await _compressRequest(request); + return db.docCreate(type, {parentId, compressedRequest}); + } else { + // Re-use the latest version if not modified since + return latestRequestVersion; + } +} + +export function getLatestByParentId (parentId) { + return db.getMostRecentlyModified(type, {parentId}); +} + +export async function restore (requestVersionId) { + const requestVersion = await getById(requestVersionId); + + // Older responses won't have versions saved with them + if (!requestVersion) { + return null; + } + + const request = await _decompressRequest(requestVersion.compressedRequest); + return models.request.update(request); +} + +function _diffRequests (rOld, rNew) { + if (!rOld) { + return true; + } + + for (const key of Object.keys(rOld)) { + // Skip fields that aren't useful + if (FIELDS_TO_IGNORE_IN_REQUEST_DIFF.includes(key)) { + continue; + } + + if (!deepEqual(rOld[key], rNew[key])) { + return true; + } + } + + return false; +} + +function _compressRequest (request) { + return new Promise((resolve, reject) => { + const str = JSON.stringify(request); + zlib.gzip(str, {}, (err, buffer) => { + if (err) { + reject(err); + } else { + const encoded = buffer.toString('base64'); + resolve(encoded); + } + }); + }); +} + +function _decompressRequest (requestJson) { + return new Promise((resolve, reject) => { + const buffer = Buffer.from(requestJson, 'base64'); + zlib.gunzip(buffer, {}, (err, jsonStr) => { + if (err) { + reject(err); + } else { + const obj = JSON.parse(jsonStr); + resolve(obj); + } + }); + }); +} diff --git a/app/models/response.js b/app/models/response.js index f1cdf7e4c8..743545ab60 100644 --- a/app/models/response.js +++ b/app/models/response.js @@ -1,5 +1,6 @@ import * as db from '../common/database'; import {MAX_RESPONSES} from '../common/constants'; +import * as models from './index'; export const name = 'Response'; export const type = 'Response'; @@ -20,6 +21,7 @@ export function init () { body: '', encoding: 'utf8', // Legacy format error: '', + requestVersionId: null, // Things from the request settingStoreCookies: null, @@ -63,6 +65,11 @@ export async function create (patch = {}) { const {parentId} = patch; + // Create request version snapshot + const request = await models.request.getById(parentId); + const requestVersion = request ? await models.requestVersion.create(request) : null; + patch.requestVersionId = requestVersion ? requestVersion._id : null; + // Delete all other responses before creating the new one const allResponses = await db.findMostRecentlyModified(type, {parentId}, MAX_RESPONSES); const recentIds = allResponses.map(r => r._id); diff --git a/app/package.json b/app/package.json index 41ba1731a2..7a5f3ea09a 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "dependencies": { "electron-context-menu": "0.9.0", "electron-squirrel-startup": "1.0.0", + "deep-equal": "1.0.1", "hkdf": "0.0.2", "httpsnippet": "1.16.5", "insomnia-importers": "1.3.8", diff --git a/app/ui/components/dropdowns/response-history-dropdown.js b/app/ui/components/dropdowns/response-history-dropdown.js index b0346ceb46..f38a434ffc 100644 --- a/app/ui/components/dropdowns/response-history-dropdown.js +++ b/app/ui/components/dropdowns/response-history-dropdown.js @@ -64,6 +64,8 @@ class ResponseHistoryDropdown extends PureComponent { renderDropdownItem (response, i) { const {activeResponseId} = this.props; const active = response._id === activeResponseId; + const message = 'Request will not be restored with this response because ' + + 'it was created before this ability was added'; return ( + {!response.requestVersionId && } ); } diff --git a/app/ui/containers/app.js b/app/ui/containers/app.js index 5a37b7ef0e..6e84535edc 100644 --- a/app/ui/containers/app.js +++ b/app/ui/containers/app.js @@ -472,8 +472,26 @@ class App extends PureComponent { this.props.handleStopLoading(requestId); } - _handleSetActiveResponse (requestId, activeResponseId) { - this._updateRequestMetaByParentId(requestId, {activeResponseId}); + async _handleSetActiveResponse (requestId, activeResponseId = null) { + await this._updateRequestMetaByParentId(requestId, {activeResponseId}); + + let response; + if (activeResponseId) { + response = await models.response.getById(activeResponseId); + } else { + response = await models.response.getLatestForRequest(requestId); + } + + const requestVersionId = response ? response.requestVersionId : 'n/a'; + const request = await models.requestVersion.restore(requestVersionId); + + if (request) { + // Refresh app to reflect changes. Using timeout because we need to + // wait for the request update to propagate. + setTimeout(() => this._wrapper._forceRequestPaneRefresh(), 500); + } else { + // Couldn't restore request. That's okay + } } _requestCreateForWorkspace () { diff --git a/package-lock.json b/package-lock.json index f5f215966c..5bb75b3500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1755,6 +1755,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, "deep-extend": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz", diff --git a/package.json b/package.json index 4d081c37e8..75110a7a7e 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "classnames": "2.2.5", "clone": "2.1.0", "codemirror": "5.24.2", + "deep-equal": "1.0.1", "electron-context-menu": "0.9.0", "electron-squirrel-startup": "1.0.0", "highlight.js": "9.12.0",