Add new multipart response viewer (#590)

This commit is contained in:
Gregory Schier 2017-11-13 01:18:27 +00:00 committed by GitHub
parent e05377b513
commit 2547e57dad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 402 additions and 39 deletions

View File

@ -193,13 +193,13 @@ export function getAuthTypeName (authType, useLong = false) {
} }
} }
export function getContentTypeFromHeaders (headers) { export function getContentTypeFromHeaders (headers, defaultValue = null) {
if (!Array.isArray(headers)) { if (!Array.isArray(headers)) {
return null; return null;
} }
const header = headers.find(({name}) => name.toLowerCase() === 'content-type'); const header = headers.find(({name}) => name.toLowerCase() === 'content-type');
return header ? header.value : null; return header ? header.value : defaultValue;
} }
export const RESPONSE_CODE_REASONS = { export const RESPONSE_CODE_REASONS = {

View File

@ -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<Props, State> {
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 (
<Modal ref={this._setModalRef}>
<ModalHeader>{title || 'Uh Oh!'}</ModalHeader>
<ModalBody className="wide pad">
{body}
</ModalBody>
</Modal>
);
}
}
export default WrapperModal;

View File

@ -316,13 +316,12 @@ class RequestPane extends React.PureComponent<Props> {
</button> </button>
</Tab> </Tab>
</TabList> </TabList>
<TabPanel className="react-tabs__tab-panel editor-wrapper"> <TabPanel key={uniqueKey} className="react-tabs__tab-panel editor-wrapper">
<BodyEditor <BodyEditor
handleUpdateRequestMimeType={updateRequestMimeType} handleUpdateRequestMimeType={updateRequestMimeType}
handleRender={handleRender} handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext} handleGetRenderContext={handleGetRenderContext}
nunjucksPowerUserMode={nunjucksPowerUserMode} nunjucksPowerUserMode={nunjucksPowerUserMode}
key={uniqueKey}
request={request} request={request}
workspace={workspace} workspace={workspace}
environmentId={environmentId} environmentId={environmentId}
@ -337,9 +336,8 @@ class RequestPane extends React.PureComponent<Props> {
</TabPanel> </TabPanel>
<TabPanel className="react-tabs__tab-panel scrollable-container"> <TabPanel className="react-tabs__tab-panel scrollable-container">
<div className="scrollable"> <div className="scrollable">
<ErrorBoundary errorClassName="font-error pad text-center"> <ErrorBoundary key={uniqueKey} errorClassName="font-error pad text-center">
<AuthWrapper <AuthWrapper
key={uniqueKey}
oAuth2Token={oAuth2Token} oAuth2Token={oAuth2Token}
showPasswords={showPasswords} showPasswords={showPasswords}
request={request} request={request}
@ -356,10 +354,9 @@ class RequestPane extends React.PureComponent<Props> {
<div className="pad pad-bottom-sm query-editor__preview"> <div className="pad pad-bottom-sm query-editor__preview">
<label className="label--small no-pad-top">Url Preview</label> <label className="label--small no-pad-top">Url Preview</label>
<code className="txt-sm block faint"> <code className="txt-sm block faint">
<ErrorBoundary <ErrorBoundary key={uniqueKey}
errorClassName="tall wide vertically-align font-error pad text-center"> errorClassName="tall wide vertically-align font-error pad text-center">
<RenderedQueryString <RenderedQueryString
key={uniqueKey}
handleRender={handleRender} handleRender={handleRender}
request={request} request={request}
/> />
@ -368,11 +365,10 @@ class RequestPane extends React.PureComponent<Props> {
</div> </div>
<div className="scrollable-container"> <div className="scrollable-container">
<div className="scrollable"> <div className="scrollable">
<ErrorBoundary <ErrorBoundary key={uniqueKey}
errorClassName="tall wide vertically-align font-error pad text-center"> errorClassName="tall wide vertically-align font-error pad text-center">
<KeyValueEditor <KeyValueEditor
sortable sortable
key={uniqueKey}
namePlaceholder="name" namePlaceholder="name"
valuePlaceholder="value" valuePlaceholder="value"
onToggleDisable={this._trackQueryToggle} onToggleDisable={this._trackQueryToggle}
@ -396,9 +392,8 @@ class RequestPane extends React.PureComponent<Props> {
</div> </div>
</TabPanel> </TabPanel>
<TabPanel className="react-tabs__tab-panel header-editor"> <TabPanel className="react-tabs__tab-panel header-editor">
<ErrorBoundary errorClassName="font-error pad text-center"> <ErrorBoundary key={uniqueKey} errorClassName="font-error pad text-center">
<RequestHeadersEditor <RequestHeadersEditor
key={uniqueKey}
headers={request.headers} headers={request.headers}
handleRender={handleRender} handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext} handleGetRenderContext={handleGetRenderContext}

View File

@ -279,9 +279,8 @@ class ResponsePane extends React.PureComponent<Props> {
</Tab> </Tab>
</TabList> </TabList>
<TabPanel className="react-tabs__tab-panel"> <TabPanel className="react-tabs__tab-panel">
<ErrorBoundary errorClassName="font-error pad text-center"> <ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseViewer <ResponseViewer
key={response._id}
// Send larger one because legacy responses have bytesContent === -1 // Send larger one because legacy responses have bytesContent === -1
responseId={response._id} responseId={response._id}
bytes={Math.max(response.bytesContent, response.bytesRead)} bytes={Math.max(response.bytesContent, response.bytesRead)}
@ -303,32 +302,27 @@ class ResponsePane extends React.PureComponent<Props> {
</TabPanel> </TabPanel>
<TabPanel className="react-tabs__tab-panel scrollable-container"> <TabPanel className="react-tabs__tab-panel scrollable-container">
<div className="scrollable pad"> <div className="scrollable pad">
<ErrorBoundary errorClassName="font-error pad text-center"> <ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseHeadersViewer <ResponseHeadersViewer headers={response.headers} />
key={response._id}
headers={response.headers}
/>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</TabPanel> </TabPanel>
<TabPanel className="react-tabs__tab-panel scrollable-container"> <TabPanel className="react-tabs__tab-panel scrollable-container">
<div className="scrollable pad"> <div className="scrollable pad">
<ErrorBoundary errorClassName="font-error pad text-center"> <ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseCookiesViewer <ResponseCookiesViewer
handleShowRequestSettings={handleShowRequestSettings} handleShowRequestSettings={handleShowRequestSettings}
cookiesSent={response.settingSendCookies} cookiesSent={response.settingSendCookies}
cookiesStored={response.settingStoreCookies} cookiesStored={response.settingStoreCookies}
showCookiesModal={showCookiesModal} showCookiesModal={showCookiesModal}
key={response._id}
headers={cookieHeaders} headers={cookieHeaders}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</TabPanel> </TabPanel>
<TabPanel className="react-tabs__tab-panel"> <TabPanel className="react-tabs__tab-panel">
<ErrorBoundary errorClassName="font-error pad text-center"> <ErrorBoundary key={response._id} errorClassName="font-error pad text-center">
<ResponseTimelineViewer <ResponseTimelineViewer
key={response._id}
timeline={response.timeline || []} timeline={response.timeline || []}
editorLineWrapping={editorLineWrapping} editorLineWrapping={editorLineWrapping}
editorFontSize={editorFontSize} editorFontSize={editorFontSize}

View File

@ -1,14 +1,17 @@
import React, {PureComponent} from 'react'; // @flow
import PropTypes from 'prop-types'; import * as React from 'react';
import CopyButton from '../base/copy-button'; import CopyButton from '../base/copy-button';
import type {ResponseHeader} from '../../../models/response';
class ResponseHeadersViewer extends PureComponent { type Props = {
headers: Array<ResponseHeader>
};
class ResponseHeadersViewer extends React.PureComponent<Props> {
render () { render () {
const {headers} = this.props; const {headers} = this.props;
const headersString = headers.map( const headersString = headers.map(h => `${h.name}: ${h.value}`).join('\n');
h => `${h.name}: ${h.value}`
).join('\n');
return [ return [
<table key='table' className="table--fancy table--striped"> <table key='table' className="table--fancy table--striped">
@ -37,8 +40,4 @@ class ResponseHeadersViewer extends PureComponent {
} }
} }
ResponseHeadersViewer.propTypes = {
headers: PropTypes.array.isRequired
};
export default ResponseHeadersViewer; export default ResponseHeadersViewer;

View File

@ -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<ResponseHeader>,
};
type Props = {
responseId: string,
bodyBuffer: Buffer | null,
contentType: string,
filter: string,
filterHistory: Array<string>,
editorFontSize: number,
editorIndentSize: number,
editorKeyMap: string,
editorLineWrapping: boolean,
url: string
};
type State = {
activePart: number,
parts: Array<Part>,
error: string | null
};
@autobind
class ResponseMultipart extends React.PureComponent<Props, State> {
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: <span>Headers for <code>{part.name}</code></span>,
body: <ResponseHeadersViewer
headers={[...part.headers, ...part.headers, ...part.headers, ...part.headers]}/>
});
}
_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<Array<Part>> {
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 (
<div className="pad monospace" style={{fontSize: editorFontSize}}>
Failed to parse multipart response: {error}
</div>
);
}
const selectedPart = parts[activePart];
return (
<div className="pad-sm tall wide"
style={{display: 'grid', gridTemplateRows: 'auto minmax(0, 1fr)'}}>
<div className="pad-bottom-sm"
style={{display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto'}}>
<div>
<Dropdown wide>
<DropdownButton className="btn btn--clicky">
<div style={{minWidth: '200px', display: 'inline-block'}}>
{selectedPart ? this._describePart(selectedPart) : 'Unknown'}
</div>
<i className="fa fa-caret-down fa--skinny space-left"/>
</DropdownButton>
{parts.map((part, i) => (
<DropdownItem key={i} value={i} onClick={this._handleSelectPart}>
{i === activePart ? <i className="fa fa-check"/> : <i className="fa fa-empty"/>}
{this._describePart(part)}
</DropdownItem>
))}
</Dropdown>
</div>
<Dropdown right>
<DropdownButton className="btn btn--clicky">
<i className="fa fa-bars"/>
</DropdownButton>
<DropdownItem onClick={this._handleViewHeaders}>
<i className="fa fa-list"/> View Headers
</DropdownItem>
<DropdownItem onClick={this._handleSaveAsFile}>
<i className="fa fa-save"/> Save as File
</DropdownItem>
</Dropdown>
</div>
{selectedPart ? (
<div className="tall wide">
<ResponseViewer
key={`${responseId}::${activePart}`}
getBody={this._getBody}
responseId={`${responseId}[${activePart}]`}
previewMode={PREVIEW_MODE_FRIENDLY}
filter={filter}
filterHistory={filterHistory}
editorFontSize={editorFontSize}
editorIndentSize={editorIndentSize}
editorKeyMap={editorKeyMap}
editorLineWrapping={editorLineWrapping}
url={url}
bytes={selectedPart.bytes || 0}
contentType={getContentTypeFromHeaders(selectedPart.headers, 'text/plain')}
updateFilter={null}
error={null}
/>
</div>
) : null}
</div>
);
}
}
export default ResponseMultipart;

View File

@ -6,6 +6,7 @@ import {shell} from 'electron';
import PDFViewer from '../pdf-viewer'; import PDFViewer from '../pdf-viewer';
import CodeEditor from '../codemirror/code-editor'; import CodeEditor from '../codemirror/code-editor';
import ResponseWebView from './response-webview'; import ResponseWebView from './response-webview';
import MultipartViewer from './response-multipart';
import ResponseRaw from './response-raw'; import ResponseRaw from './response-raw';
import ResponseError from './response-error'; import ResponseError from './response-error';
import {LARGE_RESPONSE_MB, PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW} from '../../../common/constants'; import {LARGE_RESPONSE_MB, PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_RAW} from '../../../common/constants';
@ -27,8 +28,8 @@ type Props = {
contentType: string, contentType: string,
// Optional // Optional
updateFilter?: Function, updateFilter: Function | null,
error?: string error: string | null
}; };
type State = { type State = {
@ -244,6 +245,21 @@ class ResponseViewer extends React.Component<Props, State> {
<PDFViewer body={bodyBuffer} uniqueKey={responseId}/> <PDFViewer body={bodyBuffer} uniqueKey={responseId}/>
</div> </div>
); );
} else if (previewMode === PREVIEW_MODE_FRIENDLY && ct.indexOf('multipart/') === 0) {
return (
<MultipartViewer
responseId={responseId}
bodyBuffer={bodyBuffer}
contentType={contentType}
filter={filter}
filterHistory={filterHistory}
editorFontSize={editorFontSize}
editorIndentSize={editorIndentSize}
editorKeyMap={editorKeyMap}
editorLineWrapping={editorLineWrapping}
url={url}
/>
);
} else if (previewMode === PREVIEW_MODE_FRIENDLY && ct.indexOf('audio/') === 0) { } else if (previewMode === PREVIEW_MODE_FRIENDLY && ct.indexOf('audio/') === 0) {
const justContentType = contentType.split(';')[0]; const justContentType = contentType.split(';')[0];
const base64Body = bodyBuffer.toString('base64'); const base64Body = bodyBuffer.toString('base64');

View File

@ -11,6 +11,7 @@ import autobind from 'autobind-decorator';
import classnames from 'classnames'; import classnames from 'classnames';
import {registerModal, showModal} from './modals/index'; import {registerModal, showModal} from './modals/index';
import AlertModal from './modals/alert-modal'; import AlertModal from './modals/alert-modal';
import WrapperModal from './modals/wrapper-modal';
import ErrorModal from './modals/error-modal'; import ErrorModal from './modals/error-modal';
import ChangelogModal from './modals/changelog-modal'; import ChangelogModal from './modals/changelog-modal';
import CookiesModal from './modals/cookies-modal'; import CookiesModal from './modals/cookies-modal';
@ -405,6 +406,7 @@ class Wrapper extends React.PureComponent<Props, State> {
<ErrorModal ref={registerModal}/> <ErrorModal ref={registerModal}/>
<PromptModal ref={registerModal}/> <PromptModal ref={registerModal}/>
<WrapperModal ref={registerModal}/>
<ChangelogModal ref={registerModal}/> <ChangelogModal ref={registerModal}/>
<LoginModal ref={registerModal}/> <LoginModal ref={registerModal}/>
<AskModal ref={registerModal}/> <AskModal ref={registerModal}/>

View File

@ -78,6 +78,7 @@
& > * { & > * {
display: inline-block; display: inline-block;
line-height: normal;
} }
} }

View File

@ -1,7 +1,8 @@
declare type moment = { declare type moment = {
fromNow: () => string; fromNow: () => string;
format: (fmt: string) => string;
}; };
declare module 'moment' { declare module 'moment' {
declare module.exports: (date: any) => moment declare module.exports: (date?: any) => moment
} }

3
flow-typed/multiparty.js vendored Normal file
View File

@ -0,0 +1,3 @@
declare module 'multiparty' {
declare module.exports: *
}

12
package-lock.json generated
View File

@ -4027,7 +4027,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz",
"integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
"dev": true,
"requires": { "requires": {
"pend": "1.2.0" "pend": "1.2.0"
} }
@ -7334,6 +7333,14 @@
"integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=", "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=",
"dev": true "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": { "mute-stream": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz",
@ -8146,8 +8153,7 @@
"pend": { "pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA="
"dev": true
}, },
"performance-now": { "performance-now": {
"version": "2.1.0", "version": "2.1.0",

View File

@ -148,6 +148,7 @@
"mime-types": "^2.1.14", "mime-types": "^2.1.14",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"moment": "^2.18.1", "moment": "^2.18.1",
"multiparty": "^4.1.3",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"node-forge": "^0.7.0", "node-forge": "^0.7.0",
"nunjucks": "^3.0.0", "nunjucks": "^3.0.0",