multipart viewer class->fc (#4930)

* multipart

* self review

* Apply suggestions from code review

Co-authored-by: James Gatz <jamesgatzos@gmail.com>

Co-authored-by: James Gatz <jamesgatzos@gmail.com>
This commit is contained in:
Jack Kavanagh 2022-07-07 14:28:49 +02:00 committed by GitHub
parent 5f2f67d49a
commit 8a26fdbaa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,15 +1,13 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import { format } from 'date-fns';
import { SaveDialogOptions } from 'electron';
import fs from 'fs';
import { extension as mimeExtension } from 'mime-types';
import multiparty from 'multiparty';
import path from 'path';
import React, { PureComponent } from 'react';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { PassThrough } from 'stream';
import {
AUTOBIND_CFG,
getContentTypeFromHeaders,
PREVIEW_MODE_FRIENDLY,
} from '../../../common/constants';
@ -20,9 +18,11 @@ import { DropdownItem } from '../base/dropdown/dropdown-item';
import { showModal } from '../modals/index';
import { WrapperModal } from '../modals/wrapper-modal';
import { ResponseHeadersViewer } from './response-headers-viewer';
import { ResponseViewer } from './response-viewer';
import { ResponseViewer } from './response-viewer';
interface Part {
id: number;
title: string;
name: string;
bytes: number;
value: Buffer;
@ -43,98 +43,65 @@ interface Props {
url: string;
}
interface State {
activePart: number;
parts: Part[];
error: string | null;
}
export const ResponseMultipartViewer: FC<Props> = ({
download,
disableHtmlPreviewJs,
disablePreviewLinks,
editorFontSize,
filter,
filterHistory,
responseId,
url,
bodyBuffer,
contentType,
}) => {
const [parts, setParts] = useState<Part[]>([]);
const [selectedPart, setSelectedPart] = useState<Part>();
const [error, setError] = useState<string | null>(null);
@autoBindMethodsForReact(AUTOBIND_CFG)
export class ResponseMultipartViewer extends PureComponent<Props, State> {
state: State = {
activePart: -1,
parts: [],
error: null,
};
useEffect(() => {
const init = async () => {
try {
const parts = await multipartBufferToArray({ bodyBuffer, contentType });
setParts(parts);
setSelectedPart(parts[0]);
} catch (err) {
setError(err.message);
}
};
init();
}, [bodyBuffer, contentType]);
componentDidMount() {
this._setParts();
}
const selectPart = useCallback((part: Part) => {
setSelectedPart(part);
}, []);
async _setParts() {
try {
const parts = await this._getParts();
this.setState({
parts,
activePart: 0,
error: null,
});
} catch (err) {
this.setState({
error: err.message,
});
}
}
const partBuffer = useCallback(() => selectedPart?.value, [selectedPart]);
_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 Buffer.from('');
}
return part.value;
}
_handleViewHeaders() {
const { parts, activePart } = this.state;
const part = parts[activePart];
if (!part) {
const viewHeaders = useCallback(() => {
if (!selectedPart) {
return;
}
showModal(WrapperModal, {
title: (
<span>
Headers for <code>{part.name}</code>
Headers for <code>{selectedPart.name}</code>
</span>
),
body: <ResponseHeadersViewer headers={[...part.headers]} />,
body: <ResponseHeadersViewer headers={[...selectedPart.headers]} />,
});
}
}, [selectedPart]);
async _handleSaveAsFile() {
const { parts, activePart } = this.state;
const part = parts[activePart];
if (!part) {
const saveAsFile = useCallback(async () => {
if (!selectedPart) {
return;
}
const contentType = getContentTypeFromHeaders(part.headers, 'text/plain');
const contentType = getContentTypeFromHeaders(selectedPart.headers, 'text/plain');
const extension = mimeExtension(contentType) || '.txt';
const lastDir = window.localStorage.getItem('insomnia.lastExportPath');
const dir = lastDir || window.app.getPath('desktop');
const date = format(Date.now(), 'yyyy-MM-dd');
const filename = part.filename || `${part.name}_${date}`;
const filename = selectedPart.filename || `${selectedPart.name}_${date}`;
const options: SaveDialogOptions = {
title: 'Save as File',
buttonLabel: 'Save',
@ -158,157 +125,148 @@ export class ResponseMultipartViewer extends PureComponent<Props, State> {
// Save the file
try {
// @ts-expect-error -- TSCONVERSION if filePath is undefined, don't try to write anything
await fs.promises.writeFile(filePath, part.value);
await fs.promises.writeFile(filePath, selectedPart.value);
} catch (err) {
console.warn('Failed to save multipart to file', err);
}
}
}, [selectedPart]);
_getParts(): Promise<Part[]> {
return new Promise((resolve, reject) => {
const { bodyBuffer, contentType } = this.props;
const parts: Part[] = [];
if (!bodyBuffer) {
return resolve(parts);
}
const fakeReq = new PassThrough();
// @ts-expect-error -- TSCONVERSION investigate `stream` types
fakeReq.headers = {
'content-type': contentType,
};
const form = new multiparty.Form();
form.on('part', part => {
const dataBuffers: any[] = [];
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);
});
// @ts-expect-error -- TSCONVERSION
form.parse(fakeReq);
fakeReq.write(bodyBuffer);
fakeReq.end();
});
}
render() {
const {
download,
disableHtmlPreviewJs,
disablePreviewLinks,
editorFontSize,
filter,
filterHistory,
responseId,
url,
} = 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];
if (error) {
return (
<div
className="pad-sm tall wide"
className="pad monospace"
style={{
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
fontSize: editorFontSize,
}}
>
<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
bytes={selectedPart.bytes || 0}
contentType={getContentTypeFromHeaders(selectedPart.headers, 'text/plain')}
disableHtmlPreviewJs={disableHtmlPreviewJs}
disablePreviewLinks={disablePreviewLinks}
download={download}
editorFontSize={editorFontSize}
error={null}
filter={filter}
filterHistory={filterHistory}
getBody={this._getBody}
key={`${responseId}::${activePart}`}
previewMode={PREVIEW_MODE_FRIENDLY}
responseId={`${responseId}[${activePart}]`}
url={url}
/>
</div>
) : null}
Failed to parse multipart response: {error}
</div>
);
}
if (parts.length === 0 || !selectedPart) {
return null;
}
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.title}
</div>
<i className="fa fa-caret-down fa--skinny space-left" />
</DropdownButton>
{parts.map(part => (
<DropdownItem key={part.id} value={part} onClick={selectPart}>
{selectedPart?.id === part.id ? <i className="fa fa-check" /> : <i className="fa fa-empty" />}
{part.title}
</DropdownItem>
))}
</Dropdown>
</div>
<Dropdown right>
<DropdownButton className="btn btn--clicky">
<i className="fa fa-bars" />
</DropdownButton>
<DropdownItem onClick={viewHeaders}>
<i className="fa fa-list" /> View Headers
</DropdownItem>
<DropdownItem onClick={saveAsFile}>
<i className="fa fa-save" /> Save as File
</DropdownItem>
</Dropdown>
</div>
<div className="tall wide">
<ResponseViewer
bytes={selectedPart.bytes || 0}
contentType={getContentTypeFromHeaders(selectedPart.headers, 'text/plain')}
disableHtmlPreviewJs={disableHtmlPreviewJs}
disablePreviewLinks={disablePreviewLinks}
download={download}
editorFontSize={editorFontSize}
error={null}
filter={filter}
filterHistory={filterHistory}
getBody={partBuffer}
key={`${responseId}::${selectedPart?.id}`}
previewMode={PREVIEW_MODE_FRIENDLY}
responseId={`${responseId}[${selectedPart?.id}]`}
url={url}
/>
</div>
</div>
);
};
function multipartBufferToArray({ bodyBuffer, contentType }: { bodyBuffer: Buffer | null; contentType: string }): Promise<Part[]> {
return new Promise((resolve, reject) => {
const parts: Part[] = [];
if (!bodyBuffer) {
return resolve(parts);
}
const fakeReq = new PassThrough();
// @ts-expect-error -- TSCONVERSION investigate `stream` types
fakeReq.headers = {
'content-type': contentType,
};
const form = new multiparty.Form();
let id = 0;
form.on('part', part => {
const dataBuffers: any[] = [];
part.on('data', data => {
dataBuffers.push(data);
});
part.on('error', err => {
reject(new Error(`Failed to parse part: ${err.message}`));
});
part.on('end', () => {
const title = part.filename ? `${part.name} (${part.filename})` : part.name;
parts.push({
id,
title,
value: dataBuffers ? Buffer.concat(dataBuffers) : Buffer.from(''),
name: part.name,
filename: part.filename || null,
bytes: part.byteCount,
headers: Object.keys(part.headers).map(name => ({
name,
value: part.headers[name],
})),
});
id += 1;
});
});
form.on('error', err => {
reject(err);
});
form.on('close', () => {
resolve(parts);
});
// @ts-expect-error -- TSCONVERSION
form.parse(fakeReq);
fakeReq.write(bodyBuffer);
fakeReq.end();
});
}