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 { format } from 'date-fns';
import { SaveDialogOptions } from 'electron'; import { SaveDialogOptions } from 'electron';
import fs from 'fs'; import fs from 'fs';
import { extension as mimeExtension } from 'mime-types'; import { extension as mimeExtension } from 'mime-types';
import multiparty from 'multiparty'; import multiparty from 'multiparty';
import path from 'path'; import path from 'path';
import React, { PureComponent } from 'react'; import React, { FC, useCallback, useEffect, useState } from 'react';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import { import {
AUTOBIND_CFG,
getContentTypeFromHeaders, getContentTypeFromHeaders,
PREVIEW_MODE_FRIENDLY, PREVIEW_MODE_FRIENDLY,
} from '../../../common/constants'; } from '../../../common/constants';
@ -23,6 +21,8 @@ import { ResponseHeadersViewer } from './response-headers-viewer';
import { ResponseViewer } from './response-viewer'; import { ResponseViewer } from './response-viewer';
interface Part { interface Part {
id: number;
title: string;
name: string; name: string;
bytes: number; bytes: number;
value: Buffer; value: Buffer;
@ -43,98 +43,65 @@ interface Props {
url: string; url: string;
} }
interface State { export const ResponseMultipartViewer: FC<Props> = ({
activePart: number; download,
parts: Part[]; disableHtmlPreviewJs,
error: string | null; 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) useEffect(() => {
export class ResponseMultipartViewer extends PureComponent<Props, State> { const init = async () => {
state: State = {
activePart: -1,
parts: [],
error: null,
};
componentDidMount() {
this._setParts();
}
async _setParts() {
try { try {
const parts = await this._getParts(); const parts = await multipartBufferToArray({ bodyBuffer, contentType });
this.setState({ setParts(parts);
parts, setSelectedPart(parts[0]);
activePart: 0,
error: null,
});
} catch (err) { } catch (err) {
this.setState({ setError(err.message);
error: err.message,
});
}
} }
};
init();
}, [bodyBuffer, contentType]);
_describePart(part: Part) { const selectPart = useCallback((part: Part) => {
const segments = [part.name]; setSelectedPart(part);
}, []);
if (part.filename) { const partBuffer = useCallback(() => selectedPart?.value, [selectedPart]);
segments.push(`(${part.filename})`);
}
return segments.join(' '); const viewHeaders = useCallback(() => {
} if (!selectedPart) {
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) {
return; return;
} }
showModal(WrapperModal, { showModal(WrapperModal, {
title: ( title: (
<span> <span>
Headers for <code>{part.name}</code> Headers for <code>{selectedPart.name}</code>
</span> </span>
), ),
body: <ResponseHeadersViewer headers={[...part.headers]} />, body: <ResponseHeadersViewer headers={[...selectedPart.headers]} />,
}); });
} }, [selectedPart]);
async _handleSaveAsFile() { const saveAsFile = useCallback(async () => {
const { parts, activePart } = this.state; if (!selectedPart) {
const part = parts[activePart];
if (!part) {
return; return;
} }
const contentType = getContentTypeFromHeaders(selectedPart.headers, 'text/plain');
const contentType = getContentTypeFromHeaders(part.headers, 'text/plain');
const extension = mimeExtension(contentType) || '.txt'; const extension = mimeExtension(contentType) || '.txt';
const lastDir = window.localStorage.getItem('insomnia.lastExportPath'); const lastDir = window.localStorage.getItem('insomnia.lastExportPath');
const dir = lastDir || window.app.getPath('desktop'); const dir = lastDir || window.app.getPath('desktop');
const date = format(Date.now(), 'yyyy-MM-dd'); 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 = { const options: SaveDialogOptions = {
title: 'Save as File', title: 'Save as File',
buttonLabel: 'Save', buttonLabel: 'Save',
@ -158,73 +125,11 @@ export class ResponseMultipartViewer extends PureComponent<Props, State> {
// Save the file // Save the file
try { try {
// @ts-expect-error -- TSCONVERSION if filePath is undefined, don't try to write anything // @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) { } catch (err) {
console.warn('Failed to save multipart to file', 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) { if (error) {
return ( return (
@ -239,7 +144,9 @@ export class ResponseMultipartViewer extends PureComponent<Props, State> {
); );
} }
const selectedPart = parts[activePart]; if (parts.length === 0 || !selectedPart) {
return null;
}
return ( return (
<div <div
className="pad-sm tall wide" className="pad-sm tall wide"
@ -264,14 +171,14 @@ export class ResponseMultipartViewer extends PureComponent<Props, State> {
display: 'inline-block', display: 'inline-block',
}} }}
> >
{selectedPart ? this._describePart(selectedPart) : 'Unknown'} {selectedPart.title}
</div> </div>
<i className="fa fa-caret-down fa--skinny space-left" /> <i className="fa fa-caret-down fa--skinny space-left" />
</DropdownButton> </DropdownButton>
{parts.map((part, i) => ( {parts.map(part => (
<DropdownItem key={i} value={i} onClick={this._handleSelectPart}> <DropdownItem key={part.id} value={part} onClick={selectPart}>
{i === activePart ? <i className="fa fa-check" /> : <i className="fa fa-empty" />} {selectedPart?.id === part.id ? <i className="fa fa-check" /> : <i className="fa fa-empty" />}
{this._describePart(part)} {part.title}
</DropdownItem> </DropdownItem>
))} ))}
</Dropdown> </Dropdown>
@ -280,15 +187,14 @@ export class ResponseMultipartViewer extends PureComponent<Props, State> {
<DropdownButton className="btn btn--clicky"> <DropdownButton className="btn btn--clicky">
<i className="fa fa-bars" /> <i className="fa fa-bars" />
</DropdownButton> </DropdownButton>
<DropdownItem onClick={this._handleViewHeaders}> <DropdownItem onClick={viewHeaders}>
<i className="fa fa-list" /> View Headers <i className="fa fa-list" /> View Headers
</DropdownItem> </DropdownItem>
<DropdownItem onClick={this._handleSaveAsFile}> <DropdownItem onClick={saveAsFile}>
<i className="fa fa-save" /> Save as File <i className="fa fa-save" /> Save as File
</DropdownItem> </DropdownItem>
</Dropdown> </Dropdown>
</div> </div>
{selectedPart ? (
<div className="tall wide"> <div className="tall wide">
<ResponseViewer <ResponseViewer
bytes={selectedPart.bytes || 0} bytes={selectedPart.bytes || 0}
@ -300,15 +206,67 @@ export class ResponseMultipartViewer extends PureComponent<Props, State> {
error={null} error={null}
filter={filter} filter={filter}
filterHistory={filterHistory} filterHistory={filterHistory}
getBody={this._getBody} getBody={partBuffer}
key={`${responseId}::${activePart}`} key={`${responseId}::${selectedPart?.id}`}
previewMode={PREVIEW_MODE_FRIENDLY} previewMode={PREVIEW_MODE_FRIENDLY}
responseId={`${responseId}[${activePart}]`} responseId={`${responseId}[${selectedPart?.id}]`}
url={url} url={url}
/> />
</div> </div>
) : null}
</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();
});
} }