From 2547e57dadf39ce9bbbdb6a1f8f23f1c64bdbe5c Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 13 Nov 2017 01:18:27 +0000 Subject: [PATCH] Add new multipart response viewer (#590) --- app/common/constants.js | 4 +- app/ui/components/modals/wrapper-modal.js | 52 ++++ app/ui/components/request-pane.js | 15 +- app/ui/components/response-pane.js | 16 +- .../viewers/response-headers-viewer.js | 19 +- .../components/viewers/response-multipart.js | 293 ++++++++++++++++++ app/ui/components/viewers/response-viewer.js | 20 +- app/ui/components/wrapper.js | 2 + app/ui/css/components/modal.less | 1 + flow-typed/moment.js | 3 +- flow-typed/multiparty.js | 3 + package-lock.json | 12 +- package.json | 1 + 13 files changed, 402 insertions(+), 39 deletions(-) create mode 100644 app/ui/components/modals/wrapper-modal.js create mode 100644 app/ui/components/viewers/response-multipart.js create mode 100644 flow-typed/multiparty.js diff --git a/app/common/constants.js b/app/common/constants.js index f79856656..db8bea08a 100644 --- a/app/common/constants.js +++ b/app/common/constants.js @@ -193,13 +193,13 @@ export function getAuthTypeName (authType, useLong = false) { } } -export function getContentTypeFromHeaders (headers) { +export function getContentTypeFromHeaders (headers, defaultValue = null) { if (!Array.isArray(headers)) { return null; } const header = headers.find(({name}) => name.toLowerCase() === 'content-type'); - return header ? header.value : null; + return header ? header.value : defaultValue; } export const RESPONSE_CODE_REASONS = { diff --git a/app/ui/components/modals/wrapper-modal.js b/app/ui/components/modals/wrapper-modal.js new file mode 100644 index 000000000..7e77b72a7 --- /dev/null +++ b/app/ui/components/modals/wrapper-modal.js @@ -0,0 +1,52 @@ +// @flow +import * as React from 'react'; +import autobind from 'autobind-decorator'; +import Modal from '../base/modal'; +import ModalBody from '../base/modal-body'; +import ModalHeader from '../base/modal-header'; + +type Props = {}; +type State = { + title: string, + body: React.Node +} + +@autobind +class WrapperModal extends React.PureComponent { + modal: ?Modal; + + constructor (props: Props) { + super(props); + + this.state = { + title: '', + body: null + }; + } + + _setModalRef (m: ?Modal) { + this.modal = m; + } + + show (options: Object = {}) { + const {title, body} = options; + this.setState({title, body}); + + this.modal && this.modal.show(); + } + + render () { + const {title, body} = this.state; + + return ( + + {title || 'Uh Oh!'} + + {body} + + + ); + } +} + +export default WrapperModal; diff --git a/app/ui/components/request-pane.js b/app/ui/components/request-pane.js index 839eefa41..2c9388add 100644 --- a/app/ui/components/request-pane.js +++ b/app/ui/components/request-pane.js @@ -316,13 +316,12 @@ class RequestPane extends React.PureComponent { - + {
- + {
- @@ -368,11 +365,10 @@ class RequestPane extends React.PureComponent {
- {
- + { - + {
- - + +
- +
- + +}; + +class ResponseHeadersViewer extends React.PureComponent { render () { const {headers} = this.props; - const headersString = headers.map( - h => `${h.name}: ${h.value}` - ).join('\n'); + const headersString = headers.map(h => `${h.name}: ${h.value}`).join('\n'); return [ @@ -37,8 +40,4 @@ class ResponseHeadersViewer extends PureComponent { } } -ResponseHeadersViewer.propTypes = { - headers: PropTypes.array.isRequired -}; - export default ResponseHeadersViewer; diff --git a/app/ui/components/viewers/response-multipart.js b/app/ui/components/viewers/response-multipart.js new file mode 100644 index 000000000..f03837078 --- /dev/null +++ b/app/ui/components/viewers/response-multipart.js @@ -0,0 +1,293 @@ +// @flow +import * as React from 'react'; +import * as electron from 'electron'; +import mimes from 'mime-types'; +import fs from 'fs'; +import moment from 'moment'; +import path from 'path'; +import {PassThrough} from 'stream'; +import multiparty from 'multiparty'; +import autobind from 'autobind-decorator'; +import ResponseViewer from './response-viewer'; +import {getContentTypeFromHeaders, PREVIEW_MODE_FRIENDLY} from '../../../common/constants'; +import type {ResponseHeader} from '../../../models/response'; +import {Dropdown, DropdownButton, DropdownItem} from '../base/dropdown/index'; +import {trackEvent} from '../../../analytics/index'; +import WrapperModal from '../modals/wrapper-modal'; +import {showModal} from '../modals/index'; +import ResponseHeadersViewer from './response-headers-viewer'; + +type Part = { + name: string, + bytes: number, + value: Buffer, + filename: string | null, + headers: Array, +}; + +type Props = { + responseId: string, + bodyBuffer: Buffer | null, + contentType: string, + filter: string, + filterHistory: Array, + editorFontSize: number, + editorIndentSize: number, + editorKeyMap: string, + editorLineWrapping: boolean, + url: string +}; + +type State = { + activePart: number, + parts: Array, + error: string | null +}; + +@autobind +class ResponseMultipart extends React.PureComponent { + constructor (props: Props) { + super(props); + this.state = { + activePart: -1, + parts: [], + error: null + }; + } + + componentDidMount () { + this._setParts(); + } + + async _setParts () { + try { + const parts = await this._getParts(); + this.setState({parts, activePart: 0, error: null}); + } catch (err) { + this.setState({error: err.message}); + } + } + + _describePart (part: Part) { + const segments = [part.name]; + if (part.filename) { + segments.push(`(${part.filename})`); + } + + return segments.join(' '); + } + + async _handleSelectPart (index: number) { + this.setState({activePart: index}); + } + + _getBody () { + const {parts, activePart} = this.state; + const part = parts[activePart]; + + if (!part) { + return new Buffer(''); + } + + return part.value; + } + + _handleViewHeaders () { + const {parts, activePart} = this.state; + const part = parts[activePart]; + + if (!part) { + return; + } + + showModal(WrapperModal, { + title: Headers for {part.name}, + body: + }); + } + + _handleSaveAsFile () { + const {parts, activePart} = this.state; + const part = parts[activePart]; + + if (!part) { + return; + } + + const contentType = getContentTypeFromHeaders(part.headers, 'text/plain'); + + const extension = mimes.extension(contentType) || '.txt'; + const lastDir = window.localStorage.getItem('insomnia.lastExportPath'); + const dir = lastDir || electron.remote.app.getPath('desktop'); + const date = moment().format('YYYY-MM-DD'); + const filename = part.filename || `${part.name}_${date}`; + const options = { + title: 'Save as File', + buttonLabel: 'Save', + defaultPath: path.join(dir, filename), + filters: [{ + name: 'Download', extensions: [extension] + }] + }; + + electron.remote.dialog.showSaveDialog(options, outputPath => { + if (!outputPath) { + trackEvent('Response', 'Multipart Save Cancel'); + return; + } + + // Remember last exported path + window.localStorage.setItem('insomnia.lastExportPath', path.dirname(filename)); + + // Save the file + fs.writeFile(outputPath, part.value, err => { + if (err) { + console.warn('Failed to save multipart to file', err); + trackEvent('Response', 'Multipart Save Failure'); + } else { + trackEvent('Response', 'Multipart Save Success'); + } + }); + }); + } + + _getParts (): Promise> { + return new Promise((resolve, reject) => { + const {bodyBuffer, contentType} = this.props; + const parts = []; + + if (!bodyBuffer) { + return resolve(parts); + } + + const fakeReq = new PassThrough(); + (fakeReq: Object).headers = { + 'content-type': contentType + }; + + const form = new multiparty.Form(); + form.on('part', part => { + const dataBuffers = []; + part.on('data', data => { + dataBuffers.push(data); + }); + + part.on('error', err => { + reject(new Error(`Failed to parse part: ${err.message}`)); + }); + + part.on('end', () => { + parts.push({ + value: Buffer.concat(dataBuffers), + name: part.name, + filename: part.filename || null, + bytes: part.byteCount, + headers: Object.keys(part.headers).map(name => ({name, value: part.headers[name]})) + }); + }); + }); + + form.on('error', err => { + reject(err); + }); + + form.on('close', () => { + resolve(parts); + }); + + form.parse(fakeReq); + + fakeReq.write(bodyBuffer); + fakeReq.end(); + }); + } + + render () { + const { + responseId, + filter, + filterHistory, + url, + editorFontSize, + editorIndentSize, + editorKeyMap, + editorLineWrapping + } = this.props; + + const { + activePart, + parts, + error + } = this.state; + + if (error) { + return ( +
+ Failed to parse multipart response: {error} +
+ ); + } + + const selectedPart = parts[activePart]; + + return ( +
+
+
+ + +
+ {selectedPart ? this._describePart(selectedPart) : 'Unknown'} +
+ +
+ {parts.map((part, i) => ( + + {i === activePart ? : } + {this._describePart(part)} + + ))} +
+
+ + + + + + View Headers + + + Save as File + + +
+ {selectedPart ? ( +
+ +
+ ) : null} +
+ ); + } +} + +export default ResponseMultipart; diff --git a/app/ui/components/viewers/response-viewer.js b/app/ui/components/viewers/response-viewer.js index a45c723ce..9ce06bc12 100644 --- a/app/ui/components/viewers/response-viewer.js +++ b/app/ui/components/viewers/response-viewer.js @@ -6,6 +6,7 @@ import {shell} from 'electron'; import PDFViewer from '../pdf-viewer'; import CodeEditor from '../codemirror/code-editor'; import ResponseWebView from './response-webview'; +import MultipartViewer from './response-multipart'; import ResponseRaw from './response-raw'; import ResponseError from './response-error'; import {LARGE_RESPONSE_MB, PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW} from '../../../common/constants'; @@ -27,8 +28,8 @@ type Props = { contentType: string, // Optional - updateFilter?: Function, - error?: string + updateFilter: Function | null, + error: string | null }; type State = { @@ -244,6 +245,21 @@ class ResponseViewer extends React.Component { ); + } else if (previewMode === PREVIEW_MODE_FRIENDLY && ct.indexOf('multipart/') === 0) { + return ( + + ); } else if (previewMode === PREVIEW_MODE_FRIENDLY && ct.indexOf('audio/') === 0) { const justContentType = contentType.split(';')[0]; const base64Body = bodyBuffer.toString('base64'); diff --git a/app/ui/components/wrapper.js b/app/ui/components/wrapper.js index 60c2f5d91..00e28fb04 100644 --- a/app/ui/components/wrapper.js +++ b/app/ui/components/wrapper.js @@ -11,6 +11,7 @@ import autobind from 'autobind-decorator'; import classnames from 'classnames'; import {registerModal, showModal} from './modals/index'; import AlertModal from './modals/alert-modal'; +import WrapperModal from './modals/wrapper-modal'; import ErrorModal from './modals/error-modal'; import ChangelogModal from './modals/changelog-modal'; import CookiesModal from './modals/cookies-modal'; @@ -405,6 +406,7 @@ class Wrapper extends React.PureComponent { + diff --git a/app/ui/css/components/modal.less b/app/ui/css/components/modal.less index 2309be5e1..1adda2ca8 100644 --- a/app/ui/css/components/modal.less +++ b/app/ui/css/components/modal.less @@ -78,6 +78,7 @@ & > * { display: inline-block; + line-height: normal; } } diff --git a/flow-typed/moment.js b/flow-typed/moment.js index cecab599c..88db67e31 100644 --- a/flow-typed/moment.js +++ b/flow-typed/moment.js @@ -1,7 +1,8 @@ declare type moment = { fromNow: () => string; + format: (fmt: string) => string; }; declare module 'moment' { - declare module.exports: (date: any) => moment + declare module.exports: (date?: any) => moment } diff --git a/flow-typed/multiparty.js b/flow-typed/multiparty.js new file mode 100644 index 000000000..f656efae7 --- /dev/null +++ b/flow-typed/multiparty.js @@ -0,0 +1,3 @@ +declare module 'multiparty' { + declare module.exports: * +} diff --git a/package-lock.json b/package-lock.json index d32c5de97..26c863c1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4027,7 +4027,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", - "dev": true, "requires": { "pend": "1.2.0" } @@ -7334,6 +7333,14 @@ "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=", "dev": true }, + "multiparty": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/multiparty/-/multiparty-4.1.3.tgz", + "integrity": "sha1-PEPH/LGJbhdGBDap3Qtu8WaOT5Q=", + "requires": { + "fd-slicer": "1.0.1" + } + }, "mute-stream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", @@ -8146,8 +8153,7 @@ "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", - "dev": true + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" }, "performance-now": { "version": "2.1.0", diff --git a/package.json b/package.json index 72409ac97..2a0b665f3 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "mime-types": "^2.1.14", "mkdirp": "^0.5.1", "moment": "^2.18.1", + "multiparty": "^4.1.3", "nedb": "^1.8.0", "node-forge": "^0.7.0", "nunjucks": "^3.0.0",