Convert plugin UI to Sandy

Summary:
Changelog: Updated Network plugin to Sandy UI, including several UI improvements

Converted UI to Sandy, and some minor code cleanups

Moved all mock related logic to its own dir

Fixes https://github.com/facebook/flipper/issues/2267

Reviewed By: passy

Differential Revision: D27966606

fbshipit-source-id: a64e20276d7f0966ce7a95b22557762a32c184cd
This commit is contained in:
Michel Weststrate
2021-05-06 04:26:41 -07:00
committed by Facebook GitHub Bot
parent 84d65b1a77
commit fc4a08eb55
11 changed files with 977 additions and 1436 deletions

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import * as React from 'react';
import {DataTable, DataTableColumn} from 'flipper-plugin';
import {useCallback} from 'react';
export type KeyValueItem = {
key: string;
value: string;
};
const columns: DataTableColumn<KeyValueItem>[] = [
{
key: 'key',
width: 160,
title: 'Key',
},
{
key: 'value',
title: 'Value',
wrap: true,
},
];
export function KeyValueTable({items}: {items: KeyValueItem[]}) {
const handleCopyRows = useCallback((rows: KeyValueItem[]) => {
return rows.map(({key, value}) => `${key}: ${value}`).join('\n');
}, []);
return (
<DataTable<KeyValueItem>
columns={columns}
records={items}
enableSearchbar={false}
scrollable={false}
onCopyRows={handleCopyRows}
/>
);
}

View File

@@ -1,301 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
Button,
ManagedTable,
Text,
Glyph,
styled,
colors,
Panel,
} from 'flipper';
import React, {useContext, useState, useMemo, useEffect} from 'react';
import {Route, Requests} from './types';
import {MockResponseDetails} from './MockResponseDetails';
import {NetworkRouteContext} from './index';
import {RequestId} from './types';
import {message, Checkbox, Modal, Tooltip} from 'antd';
import {NUX, Layout} from 'flipper-plugin';
type Props = {
routes: {[id: string]: Route};
highlightedRows: Set<string> | null | undefined;
requests: Requests;
};
const ColumnSizes = {route: 'flex'};
const Columns = {route: {value: 'Route', resizable: false}};
const TextEllipsis = styled(Text)({
overflowX: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
lineHeight: '18px',
paddingTop: 4,
display: 'block',
whiteSpace: 'nowrap',
});
const Icon = styled(Glyph)({
marginTop: 5,
marginRight: 8,
});
// return ids that have the same pair of requestUrl and method; this will return only the duplicate
function _duplicateIds(routes: {[id: string]: Route}): Array<RequestId> {
const idSet: {[id: string]: {[method: string]: boolean}} = {};
return Object.entries(routes).reduce((acc: Array<RequestId>, [id, route]) => {
if (idSet.hasOwnProperty(route.requestUrl)) {
if (idSet[route.requestUrl].hasOwnProperty(route.requestMethod)) {
return acc.concat(id);
}
idSet[route.requestUrl] = {
...idSet[route.requestUrl],
[route.requestMethod]: true,
};
return acc;
} else {
idSet[route.requestUrl] = {[route.requestMethod]: true};
return acc;
}
}, []);
}
function _buildRows(
routes: {[id: string]: Route},
duplicatedIds: Array<string>,
handleRemoveId: (id: string) => void,
handleEnableId: (id: string) => void,
) {
return Object.entries(routes).map(([id, route]) => ({
columns: {
route: {
value: (
<RouteRow
key={id}
text={route.requestUrl}
showWarning={duplicatedIds.includes(id)}
handleRemoveId={() => handleRemoveId(id)}
handleEnableId={() => handleEnableId(id)}
enabled={route.enabled}
/>
),
},
},
key: id,
}));
}
function RouteRow(props: {
text: string;
showWarning: boolean;
handleRemoveId: () => void;
handleEnableId: () => void;
enabled: boolean;
}) {
const tip = props.enabled
? 'Un-check to disable mock route'
: 'Check to enable mock route';
return (
<Layout.Horizontal gap>
<Tooltip title={tip} mouseEnterDelay={1.1}>
<Checkbox
onClick={props.handleEnableId}
checked={props.enabled}></Checkbox>
</Tooltip>
<Tooltip title="Click to delete mock route" mouseEnterDelay={1.1}>
<Layout.Horizontal onClick={props.handleRemoveId}>
<Icon name="cross-circle" color={colors.red} />
</Layout.Horizontal>
</Tooltip>
{props.showWarning && (
<Icon name="caution-triangle" color={colors.yellow} />
)}
{props.text.length === 0 ? (
<TextEllipsis style={{color: colors.blackAlpha50}}>
untitled
</TextEllipsis>
) : (
<TextEllipsis>{props.text}</TextEllipsis>
)}
</Layout.Horizontal>
);
}
function ManagedMockResponseRightPanel(props: {
id: string;
route: Route;
isDuplicated: boolean;
}) {
const {id, route, isDuplicated} = props;
return (
<Panel
grow={true}
collapsable={false}
floating={false}
heading={'Route Info'}>
<MockResponseDetails
key={id}
id={id}
route={route}
isDuplicated={isDuplicated}
/>
</Panel>
);
}
export function ManageMockResponsePanel(props: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
const [selectedId, setSelectedId] = useState<RequestId | null>(null);
useEffect(() => {
setSelectedId((selectedId) => {
const keys = Object.keys(props.routes);
let returnValue: string | null = null;
// selectId is null when there are no rows or it is the first time rows are shown
if (selectedId === null) {
if (keys.length === 0) {
// there are no rows
returnValue = null;
} else {
// first time rows are shown
returnValue = keys[0];
}
} else {
if (keys.includes(selectedId)) {
returnValue = selectedId;
} else {
// selectedId row value not in routes so default to first line
returnValue = keys[0];
}
}
return returnValue;
});
}, [props.routes]);
const duplicatedIds = useMemo(() => _duplicateIds(props.routes), [
props.routes,
]);
function getSelectedIds(): Set<string> {
const newSet = new Set<string>();
newSet.add(selectedId ?? '');
return newSet;
}
function getPreviousId(id: string): string | null {
const keys = Object.keys(props.routes);
const currentIndex = keys.indexOf(id);
if (currentIndex == 0) {
return null;
} else {
return keys[currentIndex - 1];
}
}
function getNextId(id: string): string | null {
const keys = Object.keys(props.routes);
const currentIndex = keys.indexOf(id);
if (currentIndex >= keys.length - 1) {
return getPreviousId(id);
} else {
return keys[currentIndex + 1];
}
}
return (
<Layout.Container style={{height: 550}}>
<Layout.Left>
<Layout.Container width={450} pad={10} gap={5}>
<Layout.Horizontal gap>
<Button
onClick={() => {
const newId = networkRouteManager.addRoute();
setSelectedId(newId);
}}>
Add Route
</Button>
<NUX
title="It is now possible to select calls from the network call list and convert them into mock routes."
placement="bottom">
<Button
onClick={() => {
if (
!props.highlightedRows ||
props.highlightedRows.size == 0
) {
message.info('No network calls have been selected');
return;
}
networkRouteManager.copyHighlightedCalls(
props.highlightedRows as Set<string>,
props.requests,
);
}}>
Copy Highlighted Calls
</Button>
</NUX>
</Layout.Horizontal>
<Panel
padded={false}
grow={true}
collapsable={false}
floating={false}
heading={'Routes'}>
<ManagedTable
hideHeader={true}
multiline={false}
columnSizes={ColumnSizes}
columns={Columns}
rows={_buildRows(
props.routes,
duplicatedIds,
(id) => {
Modal.confirm({
title: 'Are you sure you want to delete this item?',
icon: '',
onOk() {
const nextId = getNextId(id);
networkRouteManager.removeRoute(id);
setSelectedId(nextId);
},
onCancel() {},
});
},
(id) => {
networkRouteManager.enableRoute(id);
},
)}
stickyBottom={true}
autoHeight={false}
floating={false}
zebra={false}
onRowHighlighted={(selectedIds) => {
const newSelectedId =
selectedIds.length === 1 ? selectedIds[0] : null;
setSelectedId(newSelectedId);
}}
highlightedRows={getSelectedIds()}
/>
</Panel>
</Layout.Container>
<Layout.Container>
{selectedId && props.routes.hasOwnProperty(selectedId) && (
<ManagedMockResponseRightPanel
id={selectedId}
route={props.routes[selectedId]}
isDuplicated={duplicatedIds.includes(selectedId)}
/>
)}
</Layout.Container>
</Layout.Left>
</Layout.Container>
);
}

View File

@@ -1,362 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
FlexRow,
FlexColumn,
Layout,
Button,
Input,
Text,
Tabs,
Tab,
Glyph,
ManagedTable,
Select,
styled,
colors,
produce,
} from 'flipper';
import React, {useContext, useState} from 'react';
import {NetworkRouteContext, NetworkRouteManager} from './index';
import {RequestId, Route} from './types';
type Props = {
id: RequestId;
route: Route;
isDuplicated: boolean;
};
const StyledSelectContainer = styled(FlexRow)({
paddingLeft: 6,
paddingTop: 2,
paddingBottom: 24,
height: '100%',
flexGrow: 1,
});
const StyledSelect = styled(Select)({
height: '100%',
maxWidth: 400,
});
const StyledText = styled(Text)({
marginLeft: 6,
marginTop: 8,
});
const textAreaStyle: React.CSSProperties = {
width: '100%',
marginTop: 8,
height: 400,
fontSize: 15,
color: '#333',
padding: 10,
resize: 'none',
fontFamily:
'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
display: 'inline-block',
lineHeight: 1.5,
border: '1px solid #dcdee2',
borderRadius: 4,
backgroundColor: '#fff',
cursor: 'text',
WebkitTapHighlightColor: 'transparent',
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
};
const StyledInput = styled(Input)({
width: '100%',
height: 20,
marginLeft: 8,
flexGrow: 5,
});
const HeaderStyledInput = styled(Input)({
width: '100%',
height: 20,
marginTop: 6,
marginBottom: 6,
});
const HeaderGlyph = styled(Glyph)({
marginTop: 6,
marginBottom: 6,
});
const Container = styled(FlexColumn)({
flexWrap: 'nowrap',
alignItems: 'flex-start',
alignContent: 'flex-start',
flexGrow: 1,
overflow: 'hidden',
});
const Warning = styled(FlexRow)({
marginTop: 8,
});
const HeadersColumnSizes = {
close: '4%',
warning: '4%',
name: '35%',
value: 'flex',
};
const HeadersColumns = {
close: {
value: '',
resizable: false,
},
warning: {
value: '',
resizable: false,
},
name: {
value: 'Name',
resizable: false,
},
value: {
value: 'Value',
resizable: false,
},
};
const selectedHighlight = {backgroundColor: colors.highlight};
function HeaderInput(props: {
initialValue: string;
isSelected: boolean;
onUpdate: (newValue: string) => void;
}) {
const [value, setValue] = useState(props.initialValue);
return (
<HeaderStyledInput
type="text"
placeholder="Name"
value={value}
style={props.isSelected ? selectedHighlight : undefined}
onChange={(event) => setValue(event.target.value)}
onBlur={() => props.onUpdate(value)}
/>
);
}
function _buildMockResponseHeaderRows(
routeId: string,
route: Route,
selectedHeaderId: string | null,
networkRouteManager: NetworkRouteManager,
) {
return Object.entries(route.responseHeaders).map(([id, header]) => {
const selected = selectedHeaderId === id;
return {
columns: {
name: {
value: (
<HeaderInput
initialValue={header.key}
isSelected={selected}
onUpdate={(newValue: string) => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
draftHeaders[id].key = newValue;
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}
/>
),
},
value: {
value: (
<HeaderInput
initialValue={header.value}
isSelected={selected}
onUpdate={(newValue: string) => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
draftHeaders[id].value = newValue;
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}
/>
),
},
close: {
value: (
<Layout.Container
onClick={() => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
delete draftHeaders[id];
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}>
<HeaderGlyph name="cross-circle" color={colors.red} />
</Layout.Container>
),
},
},
key: id,
};
});
}
export function MockResponseDetails({id, route, isDuplicated}: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
const [activeTab, setActiveTab] = useState<string>('data');
const [selectedHeaderIds, setSelectedHeaderIds] = useState<Array<RequestId>>(
[],
);
const [nextHeaderId, setNextHeaderId] = useState(0);
const {requestUrl, requestMethod, responseData, responseStatus} = route;
let formattedResponse = '';
try {
formattedResponse = JSON.stringify(JSON.parse(responseData), null, 2);
} catch (e) {
formattedResponse = responseData;
}
return (
<Container>
<FlexRow style={{width: '100%'}}>
<StyledSelectContainer>
<StyledSelect
grow={true}
selected={requestMethod}
options={{
GET: 'GET',
POST: 'POST',
PATCH: 'PATCH',
HEAD: 'HEAD',
PUT: 'PUT',
DELETE: 'DELETE',
TRACE: 'TRACE',
OPTIONS: 'OPTIONS',
CONNECT: 'CONNECT',
}}
onChange={(text: string) =>
networkRouteManager.modifyRoute(id, {requestMethod: text})
}
/>
</StyledSelectContainer>
<StyledInput
type="text"
placeholder="URL"
value={requestUrl}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
requestUrl: event.target.value,
})
}
/>
</FlexRow>
<FlexRow style={{width: '20%'}}>
<StyledInput
type="text"
placeholder="STATUS"
value={responseStatus}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
responseStatus: event.target.value,
})
}
/>
</FlexRow>
{isDuplicated && (
<Warning>
<Glyph name="caution-triangle" color={colors.yellow} />
<Text style={{marginLeft: 5}}>
Route is duplicated (Same URL and Method)
</Text>
</Warning>
)}
<StyledText />
<Tabs
active={activeTab}
onActive={(newActiveTab) => {
if (newActiveTab != null) {
setActiveTab(newActiveTab);
}
}}>
<Tab key={'data'} label={'Data'}>
<textarea
style={textAreaStyle}
wrap="soft"
autoComplete="off"
spellCheck={false}
value={formattedResponse}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
responseData: event.target.value,
})
}
/>
</Tab>
<Tab key={'headers'} label={'Headers'}>
<Layout.Container style={{width: '100%'}}>
<Layout.Horizontal>
<Button
onClick={() => {
const newHeaders = {
...route.responseHeaders,
[nextHeaderId.toString()]: {key: '', value: ''},
};
setNextHeaderId(nextHeaderId + 1);
networkRouteManager.modifyRoute(id, {
responseHeaders: newHeaders,
});
}}
compact
padded
style={{marginBottom: 10}}>
Add Header
</Button>
</Layout.Horizontal>
<Layout.ScrollContainer>
<ManagedTable
hideHeader={true}
multiline={true}
columnSizes={HeadersColumnSizes}
columns={HeadersColumns}
rows={_buildMockResponseHeaderRows(
id,
route,
selectedHeaderIds.length === 1 ? selectedHeaderIds[0] : null,
networkRouteManager,
)}
stickyBottom={true}
autoHeight={true}
floating={false}
zebra={false}
onRowHighlighted={setSelectedHeaderIds}
highlightedRows={new Set(selectedHeaderIds)}
/>
</Layout.ScrollContainer>
</Layout.Container>
</Tab>
</Tabs>
</Container>
);
}

View File

@@ -1,81 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Button, styled, Layout, Spacer} from 'flipper';
import {ManageMockResponsePanel} from './ManageMockResponsePanel';
import {Route, Requests} from './types';
import React from 'react';
import {NetworkRouteContext} from './index';
import {useContext} from 'react';
type Props = {
routes: {[id: string]: Route};
onHide: () => void;
highlightedRows: Set<string> | null | undefined;
requests: Requests;
};
const Title = styled('div')({
fontWeight: 500,
marginBottom: 10,
marginTop: 8,
});
const StyledContainer = styled(Layout.Container)({
padding: 10,
width: 1200,
});
export function MockResponseDialog(props: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
return (
<StyledContainer pad gap width={1200}>
<Title>Mock Network Responses</Title>
<Layout.Container>
<ManageMockResponsePanel
routes={props.routes}
highlightedRows={props.highlightedRows}
requests={props.requests}
/>
</Layout.Container>
<Layout.Horizontal gap>
<Button
compact
padded
onClick={() => {
networkRouteManager.importRoutes();
}}>
Import
</Button>
<Button
compact
padded
onClick={() => {
networkRouteManager.exportRoutes();
}}>
Export
</Button>
<Button
compact
padded
onClick={() => {
networkRouteManager.clearRoutes();
}}>
Clear
</Button>
<Spacer />
<Button compact padded onClick={props.onHide}>
Close
</Button>
</Layout.Horizontal>
</StyledContainer>
);
}

View File

@@ -7,51 +7,29 @@
* @format * @format
*/ */
import {Request, Header, Insights, RetryInsights} from './types';
import {
Component,
FlexColumn,
ManagedTable,
ManagedDataInspector,
Text,
Panel,
Select,
styled,
colors,
SmallText,
} from 'flipper';
import {decodeBody, getHeaderValue} from './utils';
import {formatBytes, BodyOptions} from './index';
import React from 'react'; import React from 'react';
import {Component} from 'react';
import querystring from 'querystring'; import querystring from 'querystring';
import xmlBeautifier from 'xml-beautifier'; import xmlBeautifier from 'xml-beautifier';
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
import {Base64} from 'js-base64'; import {Base64} from 'js-base64';
const WrappingText = styled(Text)({ import {
wordWrap: 'break-word', DataInspector,
width: '100%', Layout,
lineHeight: '125%', Panel,
padding: '3px 0', styled,
}); theme,
CodeBlock,
} from 'flipper-plugin';
import {Select, Typography} from 'antd';
const KeyValueColumnSizes = { import {formatBytes, decodeBody, getHeaderValue} from './utils';
key: '30%', import {Request, Header, Insights, RetryInsights} from './types';
value: 'flex', import {BodyOptions} from './index';
}; import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
import {KeyValueItem, KeyValueTable} from './KeyValueTable';
const KeyValueColumns = { const {Text} = Typography;
key: {
value: 'Key',
resizable: false,
},
value: {
value: 'Value',
resizable: false,
},
};
type RequestDetailsProps = { type RequestDetailsProps = {
request: Request; request: Request;
@@ -59,52 +37,23 @@ type RequestDetailsProps = {
onSelectFormat: (bodyFormat: string) => void; onSelectFormat: (bodyFormat: string) => void;
}; };
export default class RequestDetails extends Component<RequestDetailsProps> { export default class RequestDetails extends Component<RequestDetailsProps> {
static Container = styled(FlexColumn)({
height: '100%',
overflow: 'auto',
});
urlColumns = (url: URL) => { urlColumns = (url: URL) => {
return [ return [
{ {
columns: { key: 'Full URL',
key: {value: <WrappingText>Full URL</WrappingText>}, value: url.href,
value: {
value: <WrappingText>{url.href}</WrappingText>,
},
},
copyText: url.href,
key: 'url',
}, },
{ {
columns: { key: 'Host',
key: {value: <WrappingText>Host</WrappingText>}, value: url.host,
value: {
value: <WrappingText>{url.host}</WrappingText>,
},
},
copyText: url.host,
key: 'host',
}, },
{ {
columns: { key: 'Path',
key: {value: <WrappingText>Path</WrappingText>}, value: url.pathname,
value: {
value: <WrappingText>{url.pathname}</WrappingText>,
},
},
copyText: url.pathname,
key: 'path',
}, },
{ {
columns: { key: 'Query String',
key: {value: <WrappingText>Query String</WrappingText>}, value: url.search,
value: {
value: <WrappingText>{url.search}</WrappingText>,
},
},
copyText: url.search,
key: 'query',
}, },
]; ];
}; };
@@ -113,51 +62,28 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
const {request, bodyFormat, onSelectFormat} = this.props; const {request, bodyFormat, onSelectFormat} = this.props;
const url = new URL(request.url); const url = new URL(request.url);
const formattedText = bodyFormat == BodyOptions.formatted; const formattedText = bodyFormat == 'formatted';
return ( return (
<RequestDetails.Container> <>
<Panel <Panel key="request" title={'Request'}>
key="request" <KeyValueTable items={this.urlColumns(url)} />
heading={'Request'}
floating={false}
padded={false}>
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={this.urlColumns(url)}
autoHeight={true}
floating={false}
zebra={false}
/>
</Panel> </Panel>
{url.search ? ( {url.search ? (
<Panel <Panel title={'Request Query Parameters'}>
heading={'Request Query Parameters'}
floating={false}
padded={false}>
<QueryInspector queryParams={url.searchParams} /> <QueryInspector queryParams={url.searchParams} />
</Panel> </Panel>
) : null} ) : null}
{request.requestHeaders.length > 0 ? ( {request.requestHeaders.length > 0 ? (
<Panel <Panel key="headers" title={'Request Headers'}>
key="headers"
heading={'Request Headers'}
floating={false}
padded={false}>
<HeaderInspector headers={request.requestHeaders} /> <HeaderInspector headers={request.requestHeaders} />
</Panel> </Panel>
) : null} ) : null}
{request.requestData != null ? ( {request.requestData != null ? (
<Panel <Panel key="requestData" title={'Request Body'} pad>
key="requestData"
heading={'Request Body'}
floating={false}
padded={!formattedText}>
<RequestBodyInspector <RequestBodyInspector
formattedText={formattedText} formattedText={formattedText}
request={request} request={request}
@@ -169,21 +95,18 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
{request.responseHeaders?.length ? ( {request.responseHeaders?.length ? (
<Panel <Panel
key={'responseheaders'} key={'responseheaders'}
heading={`Response Headers${ title={`Response Headers${
request.responseIsMock ? ' (Mocked)' : '' request.responseIsMock ? ' (Mocked)' : ''
}`} }`}>
floating={false}
padded={false}>
<HeaderInspector headers={request.responseHeaders} /> <HeaderInspector headers={request.responseHeaders} />
</Panel> </Panel>
) : null} ) : null}
<Panel <Panel
key={'responsebody'} key={'responsebody'}
heading={`Response Body${ title={`Response Body${
request.responseIsMock ? ' (Mocked)' : '' request.responseIsMock ? ' (Mocked)' : ''
}`} }`}
floating={false} pad>
padded={!formattedText}>
<ResponseBodyInspector <ResponseBodyInspector
formattedText={formattedText} formattedText={formattedText}
request={request} request={request}
@@ -191,64 +114,34 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
</Panel> </Panel>
</> </>
) : null} ) : null}
<Panel <Panel key="options" title={'Options'} collapsed pad>
key="options" <Text>Body formatting:</Text>
heading={'Options'}
floating={false}
collapsed={true}>
<Select <Select
grow value={bodyFormat}
label="Body"
selected={bodyFormat}
onChange={onSelectFormat} onChange={onSelectFormat}
options={BodyOptions} options={BodyOptions}
/> />
</Panel> </Panel>
{request.insights ? ( {request.insights ? (
<Panel <Panel key="insights" title={'Insights'} collapsed>
key="insights"
heading={'Insights'}
floating={false}
collapsed={true}>
<InsightsInspector insights={request.insights} /> <InsightsInspector insights={request.insights} />
</Panel> </Panel>
) : null} ) : null}
</RequestDetails.Container> </>
); );
} }
} }
class QueryInspector extends Component<{queryParams: URLSearchParams}> { class QueryInspector extends Component<{queryParams: URLSearchParams}> {
render() { render() {
const {queryParams} = this.props; const rows: KeyValueItem[] = [];
this.props.queryParams.forEach((value: string, key: string) => {
const rows: any = [];
queryParams.forEach((value: string, key: string) => {
rows.push({ rows.push({
columns: { key,
key: { value,
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{value}</WrappingText>,
},
},
copyText: value,
key: key,
}); });
}); });
return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
} }
} }
@@ -272,43 +165,15 @@ class HeaderInspector extends Component<
new Map(), new Map(),
); );
const rows: any = []; const rows = Array.from(computedHeaders.entries())
Array.from(computedHeaders.entries())
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1)) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
.forEach(([key, value]) => { .map(([key, value]) => ({key, value}));
rows.push({
columns: {
key: {
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{value}</WrappingText>,
},
},
copyText: value,
key,
});
});
return rows.length > 0 ? ( return rows.length > 0 ? (
<ManagedTable <KeyValueTable items={this.props.headers} />
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null; ) : null;
} }
} }
const BodyContainer = styled.div({
paddingTop: 10,
paddingBottom: 20,
});
type BodyFormatter = { type BodyFormatter = {
formatRequest?: (request: Request) => any; formatRequest?: (request: Request) => any;
formatResponse?: (request: Request) => any; formatResponse?: (request: Request) => any;
@@ -330,12 +195,12 @@ class RequestBodyInspector extends Component<{
const component = formatter.formatRequest(request); const component = formatter.formatRequest(request);
if (component) { if (component) {
return ( return (
<BodyContainer> <Layout.Container gap>
{component} {component}
<FormattedBy> <FormattedBy>
Formatted by {formatter.constructor.name} Formatted by {formatter.constructor.name}
</FormattedBy> </FormattedBy>
</BodyContainer> </Layout.Container>
); );
} }
} catch (e) { } catch (e) {
@@ -366,12 +231,12 @@ class ResponseBodyInspector extends Component<{
const component = formatter.formatResponse(request); const component = formatter.formatResponse(request);
if (component) { if (component) {
return ( return (
<BodyContainer> <Layout.Container gap>
{component} {component}
<FormattedBy> <FormattedBy>
Formatted by {formatter.constructor.name} Formatted by {formatter.constructor.name}
</FormattedBy> </FormattedBy>
</BodyContainer> </Layout.Container>
); );
} }
} catch (e) { } catch (e) {
@@ -386,17 +251,18 @@ class ResponseBodyInspector extends Component<{
} }
} }
const FormattedBy = styled(SmallText)({ const FormattedBy = styled(Text)({
marginTop: 8, marginTop: 8,
fontSize: '0.7em', fontSize: '0.7em',
textAlign: 'center', textAlign: 'center',
display: 'block', display: 'block',
color: theme.disabledColor,
}); });
const Empty = () => ( const Empty = () => (
<BodyContainer> <Layout.Container pad>
<Text>(empty)</Text> <Text>(empty)</Text>
</BodyContainer> </Layout.Container>
); );
function getRequestData(request: Request) { function getRequestData(request: Request) {
@@ -420,29 +286,19 @@ function renderRawBody(request: Request, mode: 'request' | 'response') {
mode === 'request' ? getRequestData(request) : getResponseData(request), mode === 'request' ? getRequestData(request) : getResponseData(request),
); );
return ( return (
<BodyContainer> <Layout.Container gap>
{decoded ? ( {decoded ? (
<Text selectable wordWrap="break-word"> <CodeBlock>{decoded}</CodeBlock>
{decoded}
</Text>
) : ( ) : (
<> <>
<FormattedBy>(Failed to decode)</FormattedBy> <FormattedBy>(Failed to decode)</FormattedBy>
<Text selectable wordWrap="break-word"> <CodeBlock>{data}</CodeBlock>
{data}
</Text>
</> </>
)} )}
</BodyContainer> </Layout.Container>
); );
} }
const MediaContainer = styled(FlexColumn)({
alignItems: 'center',
justifyContent: 'center',
width: '100%',
});
type ImageWithSizeProps = { type ImageWithSizeProps = {
src: string; src: string;
}; };
@@ -459,13 +315,8 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
marginBottom: 10, marginBottom: 10,
}); });
static Text = styled(Text)({ constructor(props: ImageWithSizeProps) {
color: colors.dark70, super(props);
fontSize: 14,
});
constructor(props: ImageWithSizeProps, context: any) {
super(props, context);
this.state = { this.state = {
width: 0, width: 0,
height: 0, height: 0,
@@ -487,12 +338,12 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
render() { render() {
return ( return (
<MediaContainer> <Layout.Container center>
<ImageWithSize.Image src={this.props.src} /> <ImageWithSize.Image src={this.props.src} />
<ImageWithSize.Text> <Text type="secondary">
{this.state.width} x {this.state.height} {this.state.width} x {this.state.height}
</ImageWithSize.Text> </Text>
</MediaContainer> </Layout.Container>
); );
} }
} }
@@ -528,44 +379,36 @@ class VideoFormatter {
const contentType = getHeaderValue(request.responseHeaders, 'content-type'); const contentType = getHeaderValue(request.responseHeaders, 'content-type');
if (contentType.startsWith('video/')) { if (contentType.startsWith('video/')) {
return ( return (
<MediaContainer> <Layout.Container center>
<VideoFormatter.Video controls={true}> <VideoFormatter.Video controls={true}>
<source src={request.url} type={contentType} /> <source src={request.url} type={contentType} />
</VideoFormatter.Video> </VideoFormatter.Video>
</MediaContainer> </Layout.Container>
); );
} }
}; };
} }
class JSONText extends Component<{children: any}> { class JSONText extends Component<{children: any}> {
static NoScrollbarText = styled(Text)({
overflowY: 'hidden',
});
render() { render() {
const jsonObject = this.props.children; const jsonObject = this.props.children;
return ( return (
<JSONText.NoScrollbarText code whiteSpace="pre" selectable> <CodeBlock>
{JSON.stringify(jsonObject, null, 2)} {JSON.stringify(jsonObject, null, 2)}
{'\n'} {'\n'}
</JSONText.NoScrollbarText> </CodeBlock>
); );
} }
} }
class XMLText extends Component<{body: any}> { class XMLText extends Component<{body: any}> {
static NoScrollbarText = styled(Text)({
overflowY: 'hidden',
});
render() { render() {
const xmlPretty = xmlBeautifier(this.props.body); const xmlPretty = xmlBeautifier(this.props.body);
return ( return (
<XMLText.NoScrollbarText code whiteSpace="pre" selectable> <CodeBlock>
{xmlPretty} {xmlPretty}
{'\n'} {'\n'}
</XMLText.NoScrollbarText> </CodeBlock>
); );
} }
} }
@@ -652,20 +495,14 @@ class JSONFormatter {
) { ) {
try { try {
const data = JSON.parse(body); const data = JSON.parse(body);
return ( return <DataInspector collapsed expandRoot data={data} />;
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={data}
/>
);
} catch (SyntaxError) { } catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one // Multiple top level JSON roots, map them one by one
const roots = body.split('\n'); const roots = body.split('\n');
return ( return (
<ManagedDataInspector <DataInspector
collapsed={true} collapsed
expandRoot={true} expandRoot
data={roots.map((json) => JSON.parse(json))} data={roots.map((json) => JSON.parse(json))}
/> />
); );
@@ -681,7 +518,7 @@ class LogEventFormatter {
if (typeof data.message === 'string') { if (typeof data.message === 'string') {
data.message = JSON.parse(data.message); data.message = JSON.parse(data.message);
} }
return <ManagedDataInspector expandRoot={true} data={data} />; return <DataInspector expandRoot data={data} />;
} }
} }
} }
@@ -693,7 +530,7 @@ class GraphQLBatchFormatter {
if (typeof data.queries === 'string') { if (typeof data.queries === 'string') {
data.queries = JSON.parse(data.queries); data.queries = JSON.parse(data.queries);
} }
return <ManagedDataInspector expandRoot={true} data={data} />; return <DataInspector expandRoot data={data} />;
} }
} }
} }
@@ -717,10 +554,10 @@ class GraphQLFormatter {
const requestStartMs = serverMetadata['request_start_time_ms']; const requestStartMs = serverMetadata['request_start_time_ms'];
const timeAtFlushMs = serverMetadata['time_at_flush_ms']; const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
return ( return (
<WrappingText> <Text>
{'Server wall time for initial response (ms): ' + {'Server wall time for initial response (ms): ' +
(timeAtFlushMs - requestStartMs)} (timeAtFlushMs - requestStartMs)}
</WrappingText> </Text>
); );
} }
formatRequest(request: Request) { formatRequest(request: Request) {
@@ -736,7 +573,7 @@ class GraphQLFormatter {
if (typeof data.query_params === 'string') { if (typeof data.query_params === 'string') {
data.query_params = JSON.parse(data.query_params); data.query_params = JSON.parse(data.query_params);
} }
return <ManagedDataInspector expandRoot={true} data={data} />; return <DataInspector expandRoot data={data} />;
} }
} }
@@ -760,11 +597,7 @@ class GraphQLFormatter {
return ( return (
<div> <div>
{this.parsedServerTimeForFirstFlush(data)} {this.parsedServerTimeForFirstFlush(data)}
<ManagedDataInspector <DataInspector collapsed expandRoot data={data} />
collapsed={true}
expandRoot={true}
data={data}
/>
</div> </div>
); );
} catch (SyntaxError) { } catch (SyntaxError) {
@@ -776,11 +609,7 @@ class GraphQLFormatter {
return ( return (
<div> <div>
{this.parsedServerTimeForFirstFlush(parsedResponses)} {this.parsedServerTimeForFirstFlush(parsedResponses)}
<ManagedDataInspector <DataInspector collapsed expandRoot data={parsedResponses} />
collapsed={true}
expandRoot={true}
data={parsedResponses}
/>
</div> </div>
); );
} }
@@ -796,12 +625,7 @@ class FormUrlencodedFormatter {
if (!decoded) { if (!decoded) {
return undefined; return undefined;
} }
return ( return <DataInspector expandRoot data={querystring.parse(decoded)} />;
<ManagedDataInspector
expandRoot={true}
data={querystring.parse(decoded)}
/>
);
} }
}; };
} }
@@ -921,13 +745,13 @@ class InsightsInspector extends Component<{insights: Insights}> {
return `${formatBytes(value)}/sec`; return `${formatBytes(value)}/sec`;
} }
formatRetries(retry: RetryInsights): string { formatRetries = (retry: RetryInsights): string => {
const timesWord = retry.limit === 1 ? 'time' : 'times'; const timesWord = retry.limit === 1 ? 'time' : 'times';
return `${this.formatTime(retry.timeSpent)} (${ return `${this.formatTime(retry.timeSpent)} (${
retry.count retry.count
} ${timesWord} out of ${retry.limit})`; } ${timesWord} out of ${retry.limit})`;
} };
buildRow<T>( buildRow<T>(
name: string, name: string,
@@ -936,16 +760,8 @@ class InsightsInspector extends Component<{insights: Insights}> {
): any { ): any {
return value return value
? { ? {
columns: {
key: {
value: <WrappingText>{name}</WrappingText>,
},
value: {
value: <WrappingText>{formatter(value)}</WrappingText>,
},
},
copyText: () => `${name}: ${formatter(value)}`,
key: name, key: name,
value: formatter(value),
} }
: null; : null;
} }
@@ -955,7 +771,7 @@ class InsightsInspector extends Component<{insights: Insights}> {
const {buildRow, formatTime, formatSpeed, formatRetries} = this; const {buildRow, formatTime, formatSpeed, formatRetries} = this;
const rows = [ const rows = [
buildRow('Retries', insights.retries, formatRetries.bind(this)), buildRow('Retries', insights.retries, formatRetries),
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime), buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
buildRow('Connect time', insights.connectTime, formatTime), buildRow('Connect time', insights.connectTime, formatTime),
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime), buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
@@ -968,16 +784,6 @@ class InsightsInspector extends Component<{insights: Insights}> {
buildRow('Transfer speed', insights.transferSpeed, formatSpeed), buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
].filter((r) => r != null); ].filter((r) => r != null);
return rows.length > 0 ? ( return rows.length > 0 ? <KeyValueTable items={rows} /> : null;
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
} }
} }

View File

@@ -7,42 +7,12 @@
* @format * @format
*/ */
import React, {createContext, createRef} from 'react'; import React, {createRef} from 'react';
import {Menu, message} from 'antd'; import {Button, Menu, Modal, Typography} from 'antd';
import { import {
Layout, Layout,
Button,
Glyph,
colors,
DetailSidebar, DetailSidebar,
styled,
Sheet,
} from 'flipper';
import {
Request,
RequestInfo,
ResponseInfo,
Route,
ResponseFollowupChunk,
Header,
MockRoute,
AddProtobufEvent,
PartialResponses,
Requests,
} from './types';
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
import {
convertRequestToCurlCommand,
getHeaderValue,
decodeBody,
getResponseLength,
} from './utils';
import RequestDetails from './RequestDetails';
import {URL} from 'url';
import {MockResponseDialog} from './MockResponseDialog';
import {assembleChunksIfResponseIsComplete} from './chunks';
import {
PluginClient, PluginClient,
Device, Device,
createState, createState,
@@ -52,20 +22,49 @@ import {
DataTable, DataTable,
DataTableColumn, DataTableColumn,
DataTableManager, DataTableManager,
theme,
} from 'flipper-plugin'; } from 'flipper-plugin';
import fs from 'fs'; import {
// eslint-disable-next-line Request,
import electron, {OpenDialogOptions, remote} from 'electron'; RequestInfo,
ResponseInfo,
ResponseFollowupChunk,
AddProtobufEvent,
PartialResponses,
} from './types';
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
import {
convertRequestToCurlCommand,
getHeaderValue,
getResponseLength,
formatStatus,
formatBytes,
formatDuration,
requestsToText,
} from './utils';
import RequestDetails from './RequestDetails';
import {URL} from 'url';
import {assembleChunksIfResponseIsComplete} from './chunks';
import {DeleteOutlined} from '@ant-design/icons'; import {DeleteOutlined} from '@ant-design/icons';
import {ManageMockResponsePanel} from './request-mocking/ManageMockResponsePanel';
import {
NetworkRouteContext,
NetworkRouteManager,
nullNetworkRouteManager,
Route,
MockRoute,
createNetworkManager,
computeMockRoutes,
} from './request-mocking/NetworkRouteManager';
const LOCALSTORAGE_MOCK_ROUTE_LIST_KEY = '__NETWORK_CACHED_MOCK_ROUTE_LIST'; const LOCALSTORAGE_MOCK_ROUTE_LIST_KEY = '__NETWORK_CACHED_MOCK_ROUTE_LIST';
const LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY = const LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY =
'__NETWORK_CACHED_RESPONSE_BODY_FORMAT'; '__NETWORK_CACHED_RESPONSE_BODY_FORMAT';
export const BodyOptions = { export const BodyOptions = ['formatted', 'parsed'].map((value) => ({
formatted: 'formatted', label: value,
parsed: 'parsed', value,
}; }));
type Events = { type Events = {
newRequest: RequestInfo; newRequest: RequestInfo;
@@ -78,58 +77,6 @@ type Methods = {
mockResponses(params: {routes: MockRoute[]}): Promise<void>; mockResponses(params: {routes: MockRoute[]}): Promise<void>;
}; };
const mockingStyle = {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 500,
};
const errorStyle = {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 500,
};
export function formatBytes(count: number | undefined): string {
if (typeof count !== 'number') {
return '';
}
if (count > 1024 * 1024) {
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
}
if (count > 1024) {
return (count / 1024.0).toFixed(1) + ' kB';
}
return count + ' B';
}
// State management
export interface NetworkRouteManager {
addRoute(): string | null;
modifyRoute(id: string, routeChange: Partial<Route>): void;
removeRoute(id: string): void;
enableRoute(id: string): void;
copyHighlightedCalls(highlightedRows: Set<string>, requests: Requests): void;
importRoutes(): void;
exportRoutes(): void;
clearRoutes(): void;
}
const nullNetworkRouteManager: NetworkRouteManager = {
addRoute(): string | null {
return '';
},
modifyRoute(_id: string, _routeChange: Partial<Route>) {},
removeRoute(_id: string) {},
enableRoute(_id: string) {},
copyHighlightedCalls(_highlightedRows: Set<string>, _requests: Requests) {},
importRoutes() {},
exportRoutes() {},
clearRoutes() {},
};
export const NetworkRouteContext = createContext<NetworkRouteManager>(
nullNetworkRouteManager,
);
export function plugin(client: PluginClient<Events, Methods>) { export function plugin(client: PluginClient<Events, Methods>) {
const networkRouteManager = createState<NetworkRouteManager>( const networkRouteManager = createState<NetworkRouteManager>(
nullNetworkRouteManager, nullNetworkRouteManager,
@@ -137,11 +84,12 @@ export function plugin(client: PluginClient<Events, Methods>) {
const routes = createState<{[id: string]: Route}>({}); const routes = createState<{[id: string]: Route}>({});
const nextRouteId = createState<number>(0); const nextRouteId = createState<number>(0);
const isMockResponseSupported = createState<boolean>(false); const isMockResponseSupported = createState<boolean>(false, {
persist: 'isMockResponseSupported',
});
const showMockResponseDialog = createState<boolean>(false); const showMockResponseDialog = createState<boolean>(false);
const detailBodyFormat = createState<string>( const detailBodyFormat = createState<string>(
localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) || localStorage.getItem(LOCALSTORAGE_RESPONSE_BODY_FORMAT_KEY) || 'parsed',
BodyOptions.parsed,
); );
const requests = createDataSource<Request, 'id'>([], { const requests = createDataSource<Request, 'id'>([], {
key: 'id', key: 'id',
@@ -250,9 +198,9 @@ export function plugin(client: PluginClient<Events, Methods>) {
} }
}); });
function supportsMocks(device: Device): Promise<boolean> { async function supportsMocks(device: Device): Promise<boolean> {
if (device.isArchived) { if (device.isArchived) {
return Promise.resolve(true); return isMockResponseSupported.get();
} else { } else {
return client.supportsMethod('mockResponses'); return client.supportsMethod('mockResponses');
} }
@@ -273,157 +221,14 @@ export function plugin(client: PluginClient<Events, Methods>) {
}); });
// declare new variable to be called inside the interface // declare new variable to be called inside the interface
networkRouteManager.set({ networkRouteManager.set(
addRoute(): string | null { createNetworkManager(
const newNextRouteId = nextRouteId.get(); nextRouteId,
routes.update((draft) => { routes,
draft[newNextRouteId.toString()] = { informClientMockChange,
requestUrl: '', tableManagerRef,
requestMethod: 'GET', ),
responseData: '',
responseHeaders: {},
responseStatus: '200',
enabled: true,
};
});
nextRouteId.set(newNextRouteId + 1);
return String(newNextRouteId);
},
modifyRoute(id: string, routeChange: Partial<Route>) {
if (!routes.get().hasOwnProperty(id)) {
return;
}
routes.update((draft) => {
Object.assign(draft[id], routeChange);
});
informClientMockChange(routes.get());
},
removeRoute(id: string) {
if (routes.get().hasOwnProperty(id)) {
routes.update((draft) => {
delete draft[id];
});
}
informClientMockChange(routes.get());
},
enableRoute(id: string) {
if (routes.get().hasOwnProperty(id)) {
routes.update((draft) => {
draft[id].enabled = !draft[id].enabled;
});
}
informClientMockChange(routes.get());
},
copyHighlightedCalls(
highlightedRows: Set<string> | null | undefined,
requests: Requests,
) {
// iterate through highlighted rows
highlightedRows?.forEach((row) => {
const request = requests.getById(row);
if (!request) {
return;
}
// convert headers
const headers: {[id: string]: Header} = {};
request.responseHeaders?.forEach((e) => {
headers[e.key] = e;
});
// convert data TODO: we only want this for non-binary data! See D23403095
const responseData =
request && request.responseData
? decodeBody({
headers: request.responseHeaders ?? [],
data: request.responseData,
})
: '';
const newNextRouteId = nextRouteId.get();
routes.update((draft) => {
draft[newNextRouteId.toString()] = {
requestUrl: request.url,
requestMethod: request.method,
responseData: responseData as string,
responseHeaders: headers,
responseStatus: request.status?.toString() ?? '',
enabled: true,
};
});
nextRouteId.set(newNextRouteId + 1);
});
informClientMockChange(routes.get());
},
importRoutes() {
const options: OpenDialogOptions = {
properties: ['openFile'],
filters: [{extensions: ['json'], name: 'Flipper Route Files'}],
};
remote.dialog.showOpenDialog(options).then((result) => {
const filePaths = result.filePaths;
if (filePaths.length > 0) {
fs.readFile(filePaths[0], 'utf8', (err, data) => {
if (err) {
message.error('Unable to import file');
return;
}
const importedRoutes = JSON.parse(data);
importedRoutes?.forEach((importedRoute: Route) => {
if (importedRoute != null) {
const newNextRouteId = nextRouteId.get();
routes.update((draft) => {
draft[newNextRouteId.toString()] = {
requestUrl: importedRoute.requestUrl,
requestMethod: importedRoute.requestMethod,
responseData: importedRoute.responseData as string,
responseHeaders: importedRoute.responseHeaders,
responseStatus: importedRoute.responseStatus,
enabled: true,
};
});
nextRouteId.set(newNextRouteId + 1);
}
});
informClientMockChange(routes.get());
});
}
});
},
exportRoutes() {
remote.dialog
.showSaveDialog(
// @ts-ignore This appears to work but isn't allowed by the types
null,
{
title: 'Export Routes',
defaultPath: 'NetworkPluginRoutesExport.json',
},
)
.then((result: electron.SaveDialogReturnValue) => {
const file = result.filePath;
if (!file) {
return;
}
fs.writeFile(
file,
JSON.stringify(Object.values(routes.get()), null, 2),
'utf8',
(err) => {
if (err) {
message.error('Failed to store mock routes: ' + err);
} else {
message.info('Successfully exported mock routes');
}
},
); );
});
},
clearRoutes() {
routes.set({});
informClientMockChange(routes.get());
},
});
} }
function clearLogs() { function clearLogs() {
@@ -431,29 +236,7 @@ export function plugin(client: PluginClient<Events, Methods>) {
} }
async function informClientMockChange(routes: {[id: string]: Route}) { async function informClientMockChange(routes: {[id: string]: Route}) {
const existedIdSet: {[id: string]: {[method: string]: boolean}} = {}; const filteredRoutes: {[id: string]: Route} = computeMockRoutes(routes);
const filteredRoutes: {[id: string]: Route} = Object.entries(routes).reduce(
(accRoutes, [id, route]) => {
if (existedIdSet.hasOwnProperty(route.requestUrl)) {
if (
existedIdSet[route.requestUrl].hasOwnProperty(route.requestMethod)
) {
return accRoutes;
}
existedIdSet[route.requestUrl] = {
...existedIdSet[route.requestUrl],
[route.requestMethod]: true,
};
return Object.assign({[id]: route}, accRoutes);
} else {
existedIdSet[route.requestUrl] = {
[route.requestMethod]: true,
};
return Object.assign({[id]: route}, accRoutes);
}
},
{},
);
if (isMockResponseSupported.get()) { if (isMockResponseSupported.get()) {
const routesValuesArray = Object.values(filteredRoutes); const routesValuesArray = Object.values(filteredRoutes);
@@ -584,7 +367,7 @@ export function Component() {
onRowStyle={getRowStyle} onRowStyle={getRowStyle}
tableManagerRef={instance.tableManagerRef} tableManagerRef={instance.tableManagerRef}
onSelect={instance.onSelect} onSelect={instance.onSelect}
onCopyRows={copyRow} onCopyRows={requestsToText}
onContextMenu={instance.onContextMenu} onContextMenu={instance.onContextMenu}
enableAutoScroll enableAutoScroll
extraActions={ extraActions={
@@ -598,33 +381,46 @@ export function Component() {
</Layout.Horizontal> </Layout.Horizontal>
} }
/> />
{showMockResponseDialog ? ( <Modal
<Sheet> visible={showMockResponseDialog}
{(onHide) => ( onCancel={instance.onCloseButtonPressed}
<MockResponseDialog footer={null}
routes={routes} title="Mock Network Responses"
onHide={() => { width={1200}>
onHide(); <ManageMockResponsePanel routes={routes} />
instance.onCloseButtonPressed(); </Modal>
}} <DetailSidebar width={400}>
highlightedRows={
new Set(
instance.tableManagerRef
.current!.getSelectedItems()
.map((r) => r.id),
)
}
requests={instance.requests}
/>
)}
</Sheet>
) : null}
<Sidebar /> <Sidebar />
</DetailSidebar>
</Layout.Container> </Layout.Container>
</NetworkRouteContext.Provider> </NetworkRouteContext.Provider>
); );
} }
function Sidebar() {
const instance = usePlugin(plugin);
const selectedId = useValue(instance.selectedId);
const detailBodyFormat = useValue(instance.detailBodyFormat);
const request = instance.requests.getById(selectedId!);
if (!request) {
return (
<Layout.Container pad grow center>
<Typography.Text type="secondary">No request selected</Typography.Text>
</Layout.Container>
);
}
return (
<RequestDetails
key={selectedId}
request={request}
bodyFormat={detailBodyFormat}
onSelectFormat={instance.onSelectFormat}
/>
);
}
const columns: DataTableColumn<Request>[] = [ const columns: DataTableColumn<Request>[] = [
{ {
key: 'requestTime', key: 'requestTime',
@@ -673,6 +469,14 @@ const columns: DataTableColumn<Request>[] = [
}, },
]; ];
const mockingStyle = {
color: theme.warningColor,
};
const errorStyle = {
color: theme.errorColor,
};
function getRowStyle(row: Request) { function getRowStyle(row: Request) {
return row.responseIsMock return row.responseIsMock
? mockingStyle ? mockingStyle
@@ -680,101 +484,3 @@ function getRowStyle(row: Request) {
? errorStyle ? errorStyle
: undefined; : undefined;
} }
function copyRow(requests: Request[]): string {
const request = requests[0];
if (!request || !request.url) {
return '<empty request>';
}
let copyText = `# HTTP request for ${request.domain} (ID: ${request.id})
## Request
HTTP ${request.method} ${request.url}
${request.requestHeaders
.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n')}`;
// TODO: we want decoding only for non-binary data! See D23403095
const requestData = request.requestData
? decodeBody({
headers: request.requestHeaders,
data: request.requestData,
})
: null;
const responseData = request.responseData
? decodeBody({
headers: request.responseHeaders,
data: request.responseData,
})
: null;
if (requestData) {
copyText += `\n\n${requestData}`;
}
if (request.status) {
copyText += `
## Response
HTTP ${request.status} ${request.reason}
${
request.responseHeaders
?.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n') ?? ''
}`;
}
if (responseData) {
copyText += `\n\n${responseData}`;
}
return copyText;
}
function Sidebar() {
const instance = usePlugin(plugin);
const selectedId = useValue(instance.selectedId);
const detailBodyFormat = useValue(instance.detailBodyFormat);
const request = instance.requests.getById(selectedId!);
if (!request) {
return null;
}
return (
<DetailSidebar width={500}>
<RequestDetails
key={selectedId}
request={request}
bodyFormat={detailBodyFormat}
onSelectFormat={instance.onSelectFormat}
/>
</DetailSidebar>
);
}
const Icon = styled(Glyph)({
marginTop: -3,
marginRight: 3,
});
function formatStatus(status: number | undefined) {
if (typeof status === 'number' && status >= 400 && status < 600) {
return (
<>
<Icon name="stop" color={colors.red} />
{status}
</>
);
}
return status;
}
function formatDuration(duration: number | undefined) {
if (typeof duration === 'number') return duration + 'ms';
return '';
}

View File

@@ -0,0 +1,211 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {
useContext,
useState,
useMemo,
useEffect,
useCallback,
} from 'react';
import {MockResponseDetails} from './MockResponseDetails';
import {NetworkRouteContext, Route} from './NetworkRouteManager';
import {RequestId} from '../types';
import {Checkbox, Modal, Tooltip, Button, Typography} from 'antd';
import {
NUX,
Layout,
DataList,
Toolbar,
createState,
useValue,
} from 'flipper-plugin';
import {CloseCircleOutlined, WarningOutlined} from '@ant-design/icons';
const {Text} = Typography;
type Props = {
routes: {[id: string]: Route};
};
type RouteItem = {
id: string;
title: string;
route: Route;
isDuplicate: boolean;
};
// return ids that have the same pair of requestUrl and method; this will return only the duplicate
function _duplicateIds(routes: {[id: string]: Route}): Array<RequestId> {
const idSet: {[id: string]: {[method: string]: boolean}} = {};
return Object.entries(routes).reduce((acc: Array<RequestId>, [id, route]) => {
if (idSet.hasOwnProperty(route.requestUrl)) {
if (idSet[route.requestUrl].hasOwnProperty(route.requestMethod)) {
return acc.concat(id);
}
idSet[route.requestUrl] = {
...idSet[route.requestUrl],
[route.requestMethod]: true,
};
return acc;
} else {
idSet[route.requestUrl] = {[route.requestMethod]: true};
return acc;
}
}, []);
}
export function ManageMockResponsePanel(props: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
const [selectedIdAtom] = useState(() => createState<RequestId | undefined>());
const selectedId = useValue(selectedIdAtom);
useEffect(() => {
selectedIdAtom.update((selectedId) => {
const keys = Object.keys(props.routes);
let returnValue: string | undefined = undefined;
// selectId is undefined when there are no rows or it is the first time rows are shown
if (selectedId === undefined) {
if (keys.length === 0) {
// there are no rows
returnValue = undefined;
} else {
// first time rows are shown
returnValue = keys[0];
}
} else {
if (keys.includes(selectedId)) {
returnValue = selectedId;
} else {
// selectedId row value not in routes so default to first line
returnValue = keys[0];
}
}
return returnValue;
});
}, [props.routes, selectedIdAtom]);
const duplicatedIds = useMemo(() => _duplicateIds(props.routes), [
props.routes,
]);
const items: RouteItem[] = Object.entries(props.routes).map(
([id, route]) => ({
id,
route,
title: route.requestUrl,
isDuplicate: duplicatedIds.includes(id),
}),
);
const handleDelete = useCallback(
(id: string) => {
Modal.confirm({
title: 'Are you sure you want to delete this item?',
icon: '',
onOk() {
networkRouteManager.removeRoute(id);
selectedIdAtom.set(undefined);
},
onCancel() {},
});
},
[networkRouteManager, selectedIdAtom],
);
const handleToggle = useCallback(
(id: string) => {
networkRouteManager.enableRoute(id);
},
[networkRouteManager],
);
const handleRender = useCallback(
(item: RouteItem) => (
<RouteEntry item={item} onDelete={handleDelete} onToggle={handleToggle} />
),
[handleDelete, handleToggle],
);
return (
<Layout.Left resizable style={{minHeight: 400}}>
<Layout.Top>
<Toolbar>
<Button
onClick={() => {
const newId = networkRouteManager.addRoute();
selectedIdAtom.set(newId);
}}>
Add Route
</Button>
<NUX
title="It is now possible to select calls from the network call list and convert them into mock routes."
placement="bottom">
<Button
onClick={() => {
networkRouteManager.copyHighlightedCalls();
}}>
Copy Highlighted Calls
</Button>
</NUX>
<Button onClick={networkRouteManager.importRoutes}>Import</Button>
<Button onClick={networkRouteManager.exportRoutes}>Export</Button>
<Button onClick={networkRouteManager.clearRoutes}>Clear</Button>
</Toolbar>
<DataList
items={items}
selection={selectedIdAtom}
onRenderItem={handleRender}
scrollable
/>
</Layout.Top>
<Layout.Container gap pad>
{selectedId && props.routes.hasOwnProperty(selectedId) && (
<MockResponseDetails
id={selectedId}
route={props.routes[selectedId]}
isDuplicated={duplicatedIds.includes(selectedId)}
/>
)}
</Layout.Container>
</Layout.Left>
);
}
const RouteEntry = ({
item,
onToggle,
onDelete,
}: {
item: RouteItem;
onToggle(id: string): void;
onDelete(id: string): void;
}) => {
const tip = item.route.enabled
? 'Un-check to disable mock route'
: 'Check to enable mock route';
return (
<Layout.Horizontal gap center>
<Tooltip title={tip} mouseEnterDelay={1.1}>
<Checkbox
onClick={() => onToggle(item.id)}
checked={item.route.enabled}></Checkbox>
</Tooltip>
{item.route.requestUrl.length === 0 ? (
<Text ellipsis>untitled</Text>
) : (
<Text ellipsis>{item.route.requestUrl}</Text>
)}
<Tooltip title="Click to delete mock route" mouseEnterDelay={1.1}>
<Layout.Horizontal onClick={() => onDelete(item.id)}>
<CloseCircleOutlined />
</Layout.Horizontal>
</Tooltip>
{item.isDuplicate && <WarningOutlined />}
</Layout.Horizontal>
);
};

View File

@@ -0,0 +1,217 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {useContext, useState} from 'react';
import {
NetworkRouteContext,
NetworkRouteManager,
Route,
} from './NetworkRouteManager';
import {RequestId} from '../types';
import {Button, Input, Select} from 'antd';
import {Layout, produce, Tabs, Tab, theme} from 'flipper-plugin';
import {CloseCircleOutlined, WarningOutlined} from '@ant-design/icons';
type Props = {
id: RequestId;
route: Route;
isDuplicated: boolean;
};
function HeaderInput(props: {
initialValue: string;
onUpdate: (newValue: string) => void;
style?: React.CSSProperties;
}) {
const [value, setValue] = useState(props.initialValue);
return (
<Input
type="text"
placeholder="Name"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={() => props.onUpdate(value)}
style={props.style}
/>
);
}
function ResponseHeaders({
routeId,
route,
networkRouteManager,
}: {
routeId: string;
route: Route;
networkRouteManager: NetworkRouteManager;
}) {
return (
<Layout.Container gap style={{paddingRight: theme.space.small}}>
{Object.entries(route.responseHeaders).map(([id, header]) => (
<Layout.Horizontal center gap key={id}>
<HeaderInput
initialValue={header.key}
onUpdate={(newValue: string) => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
draftHeaders[id].key = newValue;
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}
style={{width: 300}}
/>
<HeaderInput
initialValue={header.value}
onUpdate={(newValue: string) => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
draftHeaders[id].value = newValue;
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}
/>
<Layout.Container
onClick={() => {
const newHeaders = produce(
route.responseHeaders,
(draftHeaders) => {
delete draftHeaders[id];
},
);
networkRouteManager.modifyRoute(routeId, {
responseHeaders: newHeaders,
});
}}>
<CloseCircleOutlined />
</Layout.Container>
</Layout.Horizontal>
))}
</Layout.Container>
);
}
const httpMethods = [
'GET',
'POST',
'PATCH',
'HEAD',
'PUT',
'DELETE',
'TRACE',
'OPTIONS',
'CONNECT',
].map((v) => ({value: v, label: v}));
export function MockResponseDetails({id, route, isDuplicated}: Props) {
const networkRouteManager = useContext(NetworkRouteContext);
const [nextHeaderId, setNextHeaderId] = useState(0);
const {requestUrl, requestMethod, responseData, responseStatus} = route;
let formattedResponse = '';
try {
formattedResponse = JSON.stringify(JSON.parse(responseData), null, 2);
} catch (e) {
formattedResponse = responseData;
}
return (
<Layout.Container gap>
<Layout.Horizontal gap>
<Select
value={requestMethod}
options={httpMethods}
onChange={(text) =>
networkRouteManager.modifyRoute(id, {requestMethod: text})
}
/>
<Input
type="text"
placeholder="URL"
value={requestUrl}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
requestUrl: event.target.value,
})
}
style={{flex: 1}}
/>
<Input
type="text"
placeholder="STATUS"
value={responseStatus}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
responseStatus: event.target.value,
})
}
style={{width: 100}}
/>
</Layout.Horizontal>
{isDuplicated && (
<Layout.Horizontal gap>
<WarningOutlined />
Route is duplicated (Same URL and Method)
</Layout.Horizontal>
)}
<Layout.Container height={500}>
<Tabs grow>
<Tab tab={'Data'}>
<Input.TextArea
wrap="soft"
autoComplete="off"
spellCheck={false}
value={formattedResponse}
onChange={(event) =>
networkRouteManager.modifyRoute(id, {
responseData: event.target.value,
})
}
style={{flex: 1}}
/>
</Tab>
<Tab tab={'Headers'}>
<Layout.Top gap>
<Layout.Horizontal>
<Button
onClick={() => {
const newHeaders = {
...route.responseHeaders,
[nextHeaderId.toString()]: {key: '', value: ''},
};
setNextHeaderId(nextHeaderId + 1);
networkRouteManager.modifyRoute(id, {
responseHeaders: newHeaders,
});
}}>
Add Header
</Button>
</Layout.Horizontal>
<Layout.ScrollContainer>
<ResponseHeaders
routeId={id}
route={route}
networkRouteManager={networkRouteManager}
/>
</Layout.ScrollContainer>
</Layout.Top>
</Tab>
</Tabs>
</Layout.Container>
</Layout.Container>
);
}

View File

@@ -0,0 +1,241 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import fs from 'fs';
// eslint-disable-next-line
import electron, {OpenDialogOptions, remote} from 'electron';
import {Atom, DataTableManager} from 'flipper-plugin';
import {createContext} from 'react';
import {Header, Request} from '../types';
import {decodeBody} from '../utils';
import {message} from 'antd';
export type Route = {
requestUrl: string;
requestMethod: string;
responseData: string;
responseHeaders: {[id: string]: Header};
responseStatus: string;
enabled: boolean;
};
export type MockRoute = {
requestUrl: string;
method: string;
data: string;
headers: Header[];
status: string;
enabled: boolean;
};
export interface NetworkRouteManager {
addRoute(): string | undefined;
modifyRoute(id: string, routeChange: Partial<Route>): void;
removeRoute(id: string): void;
enableRoute(id: string): void;
copyHighlightedCalls(): void;
importRoutes(): void;
exportRoutes(): void;
clearRoutes(): void;
}
export const nullNetworkRouteManager: NetworkRouteManager = {
addRoute(): string | undefined {
return '';
},
modifyRoute(_id: string, _routeChange: Partial<Route>) {},
removeRoute(_id: string) {},
enableRoute(_id: string) {},
copyHighlightedCalls() {},
importRoutes() {},
exportRoutes() {},
clearRoutes() {},
};
export const NetworkRouteContext = createContext<NetworkRouteManager>(
nullNetworkRouteManager,
);
export function createNetworkManager(
nextRouteId: Atom<number>,
routes: Atom<{[id: string]: any}>,
informClientMockChange: (routes: {[id: string]: any}) => Promise<void>,
tableManagerRef: React.RefObject<DataTableManager<Request> | undefined>,
): NetworkRouteManager {
return {
addRoute(): string | undefined {
const newNextRouteId = nextRouteId.get();
routes.update((draft) => {
draft[newNextRouteId.toString()] = {
requestUrl: '',
requestMethod: 'GET',
responseData: '',
responseHeaders: {},
responseStatus: '200',
enabled: true,
};
});
nextRouteId.set(newNextRouteId + 1);
return String(newNextRouteId);
},
modifyRoute(id: string, routeChange: Partial<Route>) {
if (!routes.get().hasOwnProperty(id)) {
return;
}
routes.update((draft) => {
Object.assign(draft[id], routeChange);
});
informClientMockChange(routes.get());
},
removeRoute(id: string) {
if (routes.get().hasOwnProperty(id)) {
routes.update((draft) => {
delete draft[id];
});
}
informClientMockChange(routes.get());
},
enableRoute(id: string) {
if (routes.get().hasOwnProperty(id)) {
routes.update((draft) => {
draft[id].enabled = !draft[id].enabled;
});
}
informClientMockChange(routes.get());
},
copyHighlightedCalls() {
tableManagerRef.current?.getSelectedItems().forEach((request) => {
// convert headers
const headers: {[id: string]: Header} = {};
request.responseHeaders?.forEach((e) => {
headers[e.key] = e;
});
// convert data TODO: we only want this for non-binary data! See D23403095
const responseData =
request && request.responseData
? decodeBody({
headers: request.responseHeaders ?? [],
data: request.responseData,
})
: '';
const newNextRouteId = nextRouteId.get();
routes.update((draft) => {
draft[newNextRouteId.toString()] = {
requestUrl: request.url,
requestMethod: request.method,
responseData: responseData as string,
responseHeaders: headers,
responseStatus: request.status?.toString() ?? '',
enabled: true,
};
});
nextRouteId.set(newNextRouteId + 1);
});
informClientMockChange(routes.get());
},
importRoutes() {
const options: OpenDialogOptions = {
properties: ['openFile'],
filters: [{extensions: ['json'], name: 'Flipper Route Files'}],
};
remote.dialog.showOpenDialog(options).then((result) => {
const filePaths = result.filePaths;
if (filePaths.length > 0) {
fs.readFile(filePaths[0], 'utf8', (err, data) => {
if (err) {
message.error('Unable to import file');
return;
}
const importedRoutes = JSON.parse(data);
importedRoutes?.forEach((importedRoute: Route) => {
if (importedRoute != null) {
const newNextRouteId = nextRouteId.get();
routes.update((draft) => {
draft[newNextRouteId.toString()] = {
requestUrl: importedRoute.requestUrl,
requestMethod: importedRoute.requestMethod,
responseData: importedRoute.responseData as string,
responseHeaders: importedRoute.responseHeaders,
responseStatus: importedRoute.responseStatus,
enabled: true,
};
});
nextRouteId.set(newNextRouteId + 1);
}
});
informClientMockChange(routes.get());
});
}
});
},
exportRoutes() {
remote.dialog
.showSaveDialog(
// @ts-ignore This appears to work but isn't allowed by the types
null,
{
title: 'Export Routes',
defaultPath: 'NetworkPluginRoutesExport.json',
},
)
.then((result: electron.SaveDialogReturnValue) => {
const file = result.filePath;
if (!file) {
return;
}
fs.writeFile(
file,
JSON.stringify(Object.values(routes.get()), null, 2),
'utf8',
(err) => {
if (err) {
message.error('Failed to store mock routes: ' + err);
} else {
message.info('Successfully exported mock routes');
}
},
);
});
},
clearRoutes() {
routes.set({});
informClientMockChange(routes.get());
},
};
}
export function computeMockRoutes(routes: {[id: string]: Route}) {
const existedIdSet: {[id: string]: {[method: string]: boolean}} = {};
const filteredRoutes: {[id: string]: Route} = Object.entries(routes).reduce(
(accRoutes, [id, route]) => {
if (existedIdSet.hasOwnProperty(route.requestUrl)) {
if (
existedIdSet[route.requestUrl].hasOwnProperty(route.requestMethod)
) {
return accRoutes;
}
existedIdSet[route.requestUrl] = {
...existedIdSet[route.requestUrl],
[route.requestMethod]: true,
};
return Object.assign({[id]: route}, accRoutes);
} else {
existedIdSet[route.requestUrl] = {
[route.requestMethod]: true,
};
return Object.assign({[id]: route}, accRoutes);
}
},
{},
);
return filteredRoutes;
}

View File

@@ -101,24 +101,6 @@ export type Insights = {
retries: RetryInsights | null | undefined; retries: RetryInsights | null | undefined;
}; };
export type Route = {
requestUrl: string;
requestMethod: string;
responseData: string;
responseHeaders: {[id: string]: Header};
responseStatus: string;
enabled: boolean;
};
export type MockRoute = {
requestUrl: string;
method: string;
data: string;
headers: Header[];
status: string;
enabled: boolean;
};
export type PartialResponse = { export type PartialResponse = {
initialResponse?: ResponseInfo; initialResponse?: ResponseInfo;
followupChunks: {[id: number]: string}; followupChunks: {[id: number]: string};

View File

@@ -127,3 +127,79 @@ export function getResponseLength(request: ResponseInfo): number {
} }
return 0; return 0;
} }
export function formatDuration(duration: number | undefined) {
if (typeof duration === 'number') return duration + 'ms';
return '';
}
export function formatBytes(count: number | undefined): string {
if (typeof count !== 'number') {
return '';
}
if (count > 1024 * 1024) {
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
}
if (count > 1024) {
return (count / 1024.0).toFixed(1) + ' kB';
}
return count + ' B';
}
export function formatStatus(status: number | undefined) {
return status ? '' + status : '';
}
export function requestsToText(requests: Request[]): string {
const request = requests[0];
if (!request || !request.url) {
return '<empty request>';
}
let copyText = `# HTTP request for ${request.domain} (ID: ${request.id})
## Request
HTTP ${request.method} ${request.url}
${request.requestHeaders
.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n')}`;
// TODO: we want decoding only for non-binary data! See D23403095
const requestData = request.requestData
? decodeBody({
headers: request.requestHeaders,
data: request.requestData,
})
: null;
const responseData = request.responseData
? decodeBody({
headers: request.responseHeaders,
data: request.responseData,
})
: null;
if (requestData) {
copyText += `\n\n${requestData}`;
}
if (request.status) {
copyText += `
## Response
HTTP ${request.status} ${request.reason}
${
request.responseHeaders
?.map(
({key, value}: {key: string; value: string}): string =>
`${key}: ${String(value)}`,
)
.join('\n') ?? ''
}`;
}
if (responseData) {
copyText += `\n\n${responseData}`;
}
return copyText;
}