Skip to content

Commit 479571d

Browse files
authored
Merge pull request #135 from emaheuxPEREN/grpc_compressed_payloads
Feature: support gRPC compressed payloads
2 parents f5ec9ee + 2fdcab7 commit 479571d

File tree

13 files changed

+397
-109
lines changed

13 files changed

+397
-109
lines changed

src/components/editor/content-viewer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { observer } from 'mobx-react';
55
import { SchemaObject } from 'openapi3-ts';
66
import * as portals from 'react-reverse-portal';
77

8+
import { Headers } from '../../types';
89
import { styled } from '../../styles';
910
import { ObservablePromise, isObservablePromise } from '../../util/observable';
1011
import { asError, unreachableCheck } from '../../util/error';
1112
import { stringToBuffer } from '../../util/buffer';
13+
import { lastHeader } from '../../util/headers';
1214

1315
import { ViewableContentType } from '../../model/events/content-types';
1416
import { Formatters, isEditorFormatter } from '../../model/events/body-formatting';
@@ -22,7 +24,7 @@ interface ContentViewerProps {
2224
children: Buffer | string;
2325
schema?: SchemaObject;
2426
expanded: boolean;
25-
rawContentType?: string;
27+
headers?: Headers;
2628
contentType: ViewableContentType;
2729
editorNode: portals.HtmlPortalNode<typeof SelfSizedEditor | typeof ContainerSizedEditor>;
2830
cache: Map<Symbol, unknown>;
@@ -199,7 +201,7 @@ export class ContentViewer extends React.Component<ContentViewerProps> {
199201
return <FormatterContainer expanded={this.props.expanded}>
200202
<formatterConfig.Component
201203
content={this.contentBuffer}
202-
rawContentType={this.props.rawContentType}
204+
rawContentType={lastHeader(this.props.headers?.['content-type'])}
203205
/>
204206
</FormatterContainer>;
205207
}

src/components/editor/monaco.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async function loadMonacoEditor(retries = 5): Promise<void> {
6262
id: 'protobuf-decoding-header',
6363
command: {
6464
id: '', // No actual command defined here
65-
title: "Automatically decoded from raw Protobuf data",
65+
title: "Automatically decoded from raw Protobuf/gRPC data",
6666
},
6767
},
6868
],

src/components/send/sent-response-body.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export class SentResponseBodyCard extends React.Component<ExpandableCardProps &
8181
? getCompatibleTypes(
8282
message.contentType,
8383
lastHeader(message.headers['content-type']),
84-
message.body
84+
message.body,
85+
message.headers,
8586
)
8687
: ['text'] as const;
8788

@@ -121,7 +122,7 @@ export class SentResponseBodyCard extends React.Component<ExpandableCardProps &
121122
<ContentViewer
122123
contentId={message.id}
123124
editorNode={this.props.editorNode}
124-
rawContentType={lastHeader(message.headers['content-type'])}
125+
headers={message.headers}
125126
contentType={decodedContentType}
126127
expanded={!!expanded}
127128
cache={message.cache}

src/components/view/http/http-body-card.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export class HttpBodyCard extends React.Component<ExpandableCardProps & {
8282
const compatibleContentTypes = getCompatibleTypes(
8383
message.contentType,
8484
lastHeader(message.headers['content-type']),
85-
message.body
85+
message.body,
86+
message.headers,
8687
);
8788
const decodedContentType = compatibleContentTypes.includes(this.selectedContentType!)
8889
? this.selectedContentType!
@@ -121,7 +122,7 @@ export class HttpBodyCard extends React.Component<ExpandableCardProps & {
121122
<ContentViewer
122123
contentId={`${message.id}-${direction}`}
123124
editorNode={this.props.editorNode}
124-
rawContentType={lastHeader(message.headers['content-type'])}
125+
headers={message.headers}
125126
contentType={decodedContentType}
126127
schema={apiBodySchema}
127128
expanded={!!expanded}

src/model/events/body-formatting.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Headers } from '../../types';
12
import { styled } from '../../styles';
23

34
import { ViewableContentType } from '../events/content-types';
@@ -13,7 +14,7 @@ export interface EditorFormatter {
1314
language: string;
1415
cacheKey: Symbol;
1516
isEditApplicable: boolean; // Can you apply this manually during editing to format an input?
16-
render(content: Buffer): string | ObservablePromise<string>;
17+
render(content: Buffer, headers?: Headers): string | ObservablePromise<string>;
1718
}
1819

1920
type FormatComponentProps = {
@@ -35,17 +36,17 @@ export function isEditorFormatter(input: any): input is EditorFormatter {
3536
}
3637

3738
const buildAsyncRenderer = (formatKey: WorkerFormatterKey) =>
38-
(input: Buffer) => observablePromise(
39-
formatBufferAsync(input, formatKey)
39+
(input: Buffer, headers?: Headers) => observablePromise(
40+
formatBufferAsync(input, formatKey, headers)
4041
);
4142

4243
export const Formatters: { [key in ViewableContentType]: Formatter } = {
4344
raw: {
4445
language: 'text',
4546
cacheKey: Symbol('raw'),
4647
isEditApplicable: false,
47-
render: (input: Buffer) => {
48-
if (input.byteLength < 2000) {
48+
render: (input: Buffer, headers?: Headers) => {
49+
if (input.byteLength < 2_000) {
4950
try {
5051
// For short-ish inputs, we return synchronously - conveniently this avoids
5152
// showing the loading spinner that churns the layout in short content cases.
@@ -55,7 +56,7 @@ export const Formatters: { [key in ViewableContentType]: Formatter } = {
5556
}
5657
} else {
5758
return observablePromise(
58-
formatBufferAsync(input, 'raw')
59+
formatBufferAsync(input, 'raw', headers)
5960
);
6061
}
6162
}
@@ -102,24 +103,26 @@ export const Formatters: { [key in ViewableContentType]: Formatter } = {
102103
language: 'json',
103104
cacheKey: Symbol('json'),
104105
isEditApplicable: true,
105-
render: (input: Buffer) => {
106-
if (input.byteLength < 10000) {
106+
render: (input: Buffer, headers?: Headers) => {
107+
if (input.byteLength < 10_000) {
107108
const inputAsString = bufferToString(input);
108109

109110
try {
110111
// For short-ish inputs, we return synchronously - conveniently this avoids
111112
// showing the loading spinner that churns the layout in short content cases.
112113
return JSON.stringify(
113114
JSON.parse(inputAsString),
114-
null, 2);
115+
null,
116+
2
117+
);
115118
// ^ Same logic as in UI-worker-formatter
116119
} catch (e) {
117120
// Fallback to showing the raw un-formatted JSON:
118121
return inputAsString;
119122
}
120123
} else {
121124
return observablePromise(
122-
formatBufferAsync(input, 'json')
125+
formatBufferAsync(input, 'json', headers)
123126
);
124127
}
125128
}

src/model/events/content-types.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import * as _ from 'lodash';
2-
import { MessageBody } from '../../types';
2+
3+
import { Headers, MessageBody } from '../../types';
34
import {
45
isProbablyProtobuf,
5-
isValidProtobuf
6+
isValidProtobuf,
7+
isProbablyGrpcProto,
8+
isValidGrpcProto,
69
} from '../../util/protobuf';
710

811
// Simplify a mime type as much as we can, without throwing any errors
@@ -21,7 +24,7 @@ export const getBaseContentType = (mimeType: string | undefined) => {
2124
return type + '/' + combinedSubTypes;
2225
}
2326

24-
// Otherwise, wr collect a list of types from most specific to most generic: [svg, xml] for image/svg+xml
27+
// Otherwise, we collect a list of types from most specific to most generic: [svg, xml] for image/svg+xml
2528
// and then look through in order to see if there are any matches here:
2629
const subTypes = combinedSubTypes.split('+');
2730
const possibleTypes = subTypes.map(st => type + '/' + st);
@@ -112,6 +115,9 @@ const mimeTypeToContentTypeMap: { [mimeType: string]: ViewableContentType } = {
112115
'application/x-protobuffer': 'protobuf', // Commonly seen in Google apps
113116

114117
'application/grpc+proto': 'grpc-proto', // Used in GRPC requests (protobuf but with special headers)
118+
'application/grpc+protobuf': 'grpc-proto',
119+
'application/grpc-proto': 'grpc-proto',
120+
'application/grpc-protobuf': 'grpc-proto',
115121

116122
'application/octet-stream': 'raw'
117123
} as const;
@@ -147,19 +153,32 @@ export function getDefaultMimeType(contentType: ViewableContentType): string {
147153
return _.findKey(mimeTypeToContentTypeMap, (c) => c === contentType)!;
148154
}
149155

150-
function isValidBase64Byte(byte: number) {
156+
function isAlphaNumOrEquals(byte: number) {
151157
return (byte >= 65 && byte <= 90) || // A-Z
152158
(byte >= 97 && byte <= 122) || // a-z
153159
(byte >= 48 && byte <= 57) || // 0-9
154-
byte === 43 || // +
155-
byte === 47 || // /
156160
byte === 61; // =
157161
}
158162

163+
function isValidStandardBase64Byte(byte: number) {
164+
// + / (standard)
165+
return byte === 43 ||
166+
byte === 47 ||
167+
isAlphaNumOrEquals(byte);
168+
}
169+
170+
function isValidURLSafeBase64Byte(byte: number) {
171+
// - _ (URL-safe version)
172+
return byte === 45 ||
173+
byte === 95 ||
174+
isAlphaNumOrEquals(byte);
175+
}
176+
159177
export function getCompatibleTypes(
160178
contentType: ViewableContentType,
161179
rawContentType: string | undefined,
162-
body: MessageBody | Buffer | undefined
180+
body: MessageBody | Buffer | undefined,
181+
headers?: Headers,
163182
): ViewableContentType[] {
164183
let types = new Set([contentType]);
165184

@@ -180,33 +199,41 @@ export function getCompatibleTypes(
180199
types.add('xml');
181200
}
182201

183-
if (!types.has('grpc-proto') && rawContentType === 'application/grpc') {
184-
types.add('grpc-proto')
185-
}
186-
187202
if (
188203
body &&
189-
isProbablyProtobuf(body) &&
190204
!types.has('protobuf') &&
191205
!types.has('grpc-proto') &&
206+
isProbablyProtobuf(body) &&
192207
// If it's probably unmarked protobuf, and it's a manageable size, try
193208
// parsing it just to check:
194209
(body.length < 100_000 && isValidProtobuf(body))
195210
) {
196211
types.add('protobuf');
197212
}
198213

214+
if (
215+
body &&
216+
!types.has('grpc-proto') &&
217+
isProbablyGrpcProto(body, headers ?? {}) &&
218+
// If it's probably unmarked gRPC, and it's a manageable size, try
219+
// parsing it just to check:
220+
(body.length < 100_000 && isValidGrpcProto(body, headers ?? {}))
221+
) {
222+
types.add('grpc-proto');
223+
}
224+
199225
// SVGs can always be shown as XML
200226
if (rawContentType && rawContentType.startsWith('image/svg')) {
201227
types.add('xml');
202228
}
203229

204230
if (
205231
body &&
206-
body.length > 0 &&
207-
body.length % 4 === 0 && // Multiple of 4 bytes
208-
body.length < 1000 * 100 && // < 100 KB of content
209-
body.every(isValidBase64Byte)
232+
!types.has('base64') &&
233+
body.length >= 8 &&
234+
// body.length % 4 === 0 && // Multiple of 4 bytes (final padding may be omitted)
235+
body.length < 100_000 && // < 100 KB of content
236+
(body.every(isValidStandardBase64Byte) || body.every(isValidURLSafeBase64Byte))
210237
) {
211238
types.add('base64');
212239
}

src/model/http/har.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ interface HarLog extends HarFormat.Log {
5151
export type RequestContentData = {
5252
text: string;
5353
size: number;
54-
encoding?: 'base64';
54+
encoding: 'base64';
5555
comment?: string;
5656
};
5757

5858
export interface ExtendedHarRequest extends HarFormat.Request {
5959
_requestBodyStatus?:
6060
| 'discarded:too-large'
61-
| 'discarded:not-representable'
61+
| 'discarded:not-representable' // to indicate that extended field `_content` is populated with base64 `postData`
6262
| 'discarded:not-decodable';
6363
_content?: RequestContentData;
6464
_trailers?: HarFormat.Header[];
@@ -302,7 +302,7 @@ async function generateHarResponse(
302302

303303
const decoded = await response.body.decodedPromise;
304304

305-
let responseContent: { text: string, encoding?: string } | { comment: string};
305+
let responseContent: { text: string, encoding?: string } | { comment: string };
306306
try {
307307
if (!decoded || decoded.byteLength > options.bodySizeLimit) {
308308
// If no body or the body is too large, don't include it
@@ -751,7 +751,7 @@ function parseHttpVersion(
751751
}
752752

753753
function parseHarRequestContents(data: RequestContentData): Buffer {
754-
if (data.encoding && Buffer.isEncoding(data.encoding)) {
754+
if (Buffer.isEncoding(data.encoding)) {
755755
return Buffer.from(data.text, data.encoding);
756756
}
757757

src/services/ui-worker-api.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
ParseCertResponse
2222
} from './ui-worker';
2323

24-
import { Omit } from '../types';
24+
import { Headers, Omit } from '../types';
2525
import type { ApiMetadata, ApiSpec } from '../model/api/api-interfaces';
2626
import { WorkerFormatterKey } from './ui-worker-formatters';
2727

@@ -149,10 +149,11 @@ export async function parseCert(buffer: ArrayBuffer) {
149149
})).result;
150150
}
151151

152-
export async function formatBufferAsync(buffer: ArrayBuffer, format: WorkerFormatterKey) {
152+
export async function formatBufferAsync(buffer: ArrayBuffer, format: WorkerFormatterKey, headers?: Headers) {
153153
return (await callApi<FormatRequest, FormatResponse>({
154154
type: 'format',
155155
buffer,
156-
format
156+
format,
157+
headers,
157158
})).formatted;
158159
}

0 commit comments

Comments
 (0)