Skip to content

Commit e35966c

Browse files
committed
Be more forceful in content type detection
If you have a vague or likely wrong content-type, and we're pretty sure we have the right content type, we now override the default formatting to the correct case. You can still switch back manually, but this catches lots of scenarios of badly-tagged JSON particularly, and should smooth out lots of related UX (like perf issues formatting large non-HTML as HTML).
1 parent 0521c05 commit e35966c

File tree

5 files changed

+222
-71
lines changed

5 files changed

+222
-71
lines changed

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

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import * as _ from 'lodash';
22
import * as React from 'react';
3-
import { observable, autorun, action } from 'mobx';
3+
import { observable, autorun, action, computed } from 'mobx';
44
import { disposeOnUnmount, observer } from 'mobx-react';
55
import * as portals from 'react-reverse-portal';
66

77
import { ExchangeMessage } from '../../types';
88

9-
import { ErrorLike } from '../../util/error';
109
import { getHeaderValue } from '../../model/http/headers';
1110

12-
import { ViewableContentType, getCompatibleTypes } from '../../model/events/content-types';
11+
import { ContentViewOptions, ViewableContentType, getCompatibleTypes } from '../../model/events/content-types';
1312

1413
import { ExpandableCardProps } from '../common/card';
1514

@@ -56,6 +55,22 @@ export class SentResponseBodyCard extends React.Component<ExpandableCardProps &
5655
}));
5756
}
5857

58+
@computed
59+
get contentViewOptions(): ContentViewOptions {
60+
const { message } = this.props;
61+
if (!message) return {
62+
preferredContentType: 'text',
63+
availableContentTypes: ['text']
64+
};
65+
66+
return getCompatibleTypes(
67+
message.contentType,
68+
getHeaderValue(message.headers, 'content-type'),
69+
message.body,
70+
message.headers,
71+
);
72+
}
73+
5974
@action.bound
6075
onChangeContentType(contentType: ViewableContentType | undefined) {
6176
if (contentType === this.props.message?.contentType) {
@@ -77,18 +92,14 @@ export class SentResponseBodyCard extends React.Component<ExpandableCardProps &
7792
ariaLabel
7893
} = this.props;
7994

80-
const compatibleContentTypes = message
81-
? getCompatibleTypes(
82-
message.contentType,
83-
getHeaderValue(message.headers, 'content-type'),
84-
message.body,
85-
message.headers,
86-
)
87-
: ['text'] as const;
95+
const {
96+
preferredContentType,
97+
availableContentTypes
98+
} = this.contentViewOptions;
8899

89-
const decodedContentType = _.includes(compatibleContentTypes, this.selectedContentType)
100+
const decodedContentType = availableContentTypes.includes(this.selectedContentType!)
90101
? this.selectedContentType!
91-
: (message?.contentType ?? 'text');
102+
: preferredContentType;
92103

93104
if (message?.body.isDecoded()) {
94105
// We have successfully decoded the body content, show it:
@@ -110,7 +121,7 @@ export class SentResponseBodyCard extends React.Component<ExpandableCardProps &
110121
onCollapseToggled={onCollapseToggled}
111122

112123
selectedContentType={decodedContentType}
113-
contentTypeOptions={compatibleContentTypes}
124+
contentTypeOptions={availableContentTypes}
114125
onChangeContentType={this.onChangeContentType}
115126

116127
isPaidUser={isPaidUser}
@@ -199,7 +210,7 @@ export class SentResponseBodyCard extends React.Component<ExpandableCardProps &
199210
onCollapseToggled={onCollapseToggled}
200211

201212
selectedContentType={decodedContentType}
202-
contentTypeOptions={compatibleContentTypes}
213+
contentTypeOptions={availableContentTypes}
203214
onChangeContentType={this.onChangeContentType}
204215
isPaidUser={isPaidUser}
205216
/>

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as _ from 'lodash';
22
import * as React from 'react';
3-
import { observable, autorun, action } from 'mobx';
3+
import { observable, autorun, action, computed } from 'mobx';
44
import { disposeOnUnmount, observer } from 'mobx-react';
55
import type { SchemaObject } from 'openapi-directory';
66
import * as portals from 'react-reverse-portal';
@@ -56,9 +56,20 @@ export class HttpBodyCard extends React.Component<ExpandableCardProps & {
5656
}));
5757
}
5858

59+
@computed
60+
get contentViewOptions() {
61+
const { message } = this.props;
62+
return getCompatibleTypes(
63+
message.contentType,
64+
getHeaderValue(message.headers, 'content-type'),
65+
message.body,
66+
message.headers,
67+
);
68+
}
69+
5970
@action.bound
6071
onChangeContentType(contentType: ViewableContentType | undefined) {
61-
if (contentType === this.props.message.contentType) {
72+
if (contentType === this.contentViewOptions.preferredContentType) {
6273
this.selectedContentType = undefined;
6374
} else {
6475
this.selectedContentType = contentType;
@@ -82,15 +93,14 @@ export class HttpBodyCard extends React.Component<ExpandableCardProps & {
8293
editorNode
8394
} = this.props;
8495

85-
const compatibleContentTypes = getCompatibleTypes(
86-
message.contentType,
87-
getHeaderValue(message.headers, 'content-type'),
88-
message.body,
89-
message.headers,
90-
);
91-
const decodedContentType = compatibleContentTypes.includes(this.selectedContentType!)
96+
const {
97+
preferredContentType,
98+
availableContentTypes
99+
} = this.contentViewOptions;
100+
101+
const decodedContentType = availableContentTypes.includes(this.selectedContentType!)
92102
? this.selectedContentType!
93-
: message.contentType;
103+
: preferredContentType;
94104

95105
if (message.body.isDecoded()) {
96106
// We have successfully decoded the body content, show it:
@@ -113,7 +123,7 @@ export class HttpBodyCard extends React.Component<ExpandableCardProps & {
113123
onCollapseToggled={onCollapseToggled}
114124

115125
selectedContentType={decodedContentType}
116-
contentTypeOptions={compatibleContentTypes}
126+
contentTypeOptions={availableContentTypes}
117127
onChangeContentType={this.onChangeContentType}
118128

119129
isPaidUser={isPaidUser}
@@ -208,7 +218,7 @@ export class HttpBodyCard extends React.Component<ExpandableCardProps & {
208218
onCollapseToggled={onCollapseToggled}
209219

210220
selectedContentType={decodedContentType}
211-
contentTypeOptions={compatibleContentTypes}
221+
contentTypeOptions={availableContentTypes}
212222
onChangeContentType={this.onChangeContentType}
213223
isPaidUser={isPaidUser}
214224
/>

src/components/view/stream-message-rows.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as _ from 'lodash';
22
import * as React from 'react';
3-
import { observable, autorun, action } from 'mobx';
3+
import { observable, autorun, action, computed } from 'mobx';
44
import { disposeOnUnmount, observer } from 'mobx-react';
55
import * as portals from 'react-reverse-portal';
66

@@ -213,6 +213,16 @@ export class StreamMessageEditorRow extends React.Component<MessageEditorRowProp
213213
}
214214
}
215215

216+
@computed
217+
get contentViewOptions() {
218+
const { message } = this.props;
219+
return getCompatibleTypes(
220+
message.contentType,
221+
undefined,
222+
asBuffer(message.content)
223+
);
224+
}
225+
216226
@action.bound
217227
setContentType(contentType: ViewableContentType | undefined) {
218228
if (contentType === this.props.message.contentType) {
@@ -225,15 +235,14 @@ export class StreamMessageEditorRow extends React.Component<MessageEditorRowProp
225235
render() {
226236
const { message, isPaidUser, onExportMessage, editorNode, streamId } = this.props;
227237

228-
const compatibleContentTypes = getCompatibleTypes(
229-
message.contentType,
230-
undefined,
231-
asBuffer(message.content)
232-
);
238+
const {
239+
preferredContentType,
240+
availableContentTypes
241+
} = this.contentViewOptions;
233242

234-
const contentType = _.includes(compatibleContentTypes, this.selectedContentType)
243+
const contentType = availableContentTypes.includes(this.selectedContentType!)
235244
? this.selectedContentType!
236-
: message.contentType;
245+
: preferredContentType;
237246

238247
const messageDirection = message.direction === 'sent' ? 'left' : 'right';
239248

@@ -287,7 +296,7 @@ export class StreamMessageEditorRow extends React.Component<MessageEditorRowProp
287296
<PillSelector<ViewableContentType>
288297
onChange={this.setContentType}
289298
value={contentType}
290-
options={compatibleContentTypes}
299+
options={availableContentTypes}
291300
nameFormatter={getContentEditorName}
292301
/>
293302
<Pill>{ getReadableSize(message.content.byteLength) }</Pill>

src/model/events/content-types.ts

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isValidGrpcProto,
99
} from '../../util/protobuf';
1010
import { isProbablyJson, isProbablyJsonRecords } from '../../util/json';
11+
import { isProbablyUtf8 } from '../../util/buffer';
1112

1213
// Simplify a mime type as much as we can, without throwing any errors
1314
export const getBaseContentType = (mimeType: string | undefined) => {
@@ -186,75 +187,150 @@ function isValidURLSafeBase64Byte(byte: number) {
186187
isAlphaNumOrEquals(byte);
187188
}
188189

190+
export interface ContentViewOptions {
191+
preferredContentType: ViewableContentType;
192+
availableContentTypes: ViewableContentType[];
193+
}
194+
189195
export function getCompatibleTypes(
190196
contentType: ViewableContentType,
191197
rawContentType: string | undefined,
192-
body: MessageBody | Buffer | undefined,
198+
messageBody: MessageBody | Buffer | undefined,
193199
headers?: Headers,
194-
): ViewableContentType[] {
195-
let types = new Set([contentType]);
196-
197-
if (body && !Buffer.isBuffer(body)) {
198-
body = body.decodedData;
200+
): ContentViewOptions {
201+
let preferredType = contentType;
202+
let availableTypes = new Set([contentType]);
203+
204+
let body: Buffer | undefined;
205+
if (messageBody && Buffer.isBuffer(messageBody)) {
206+
body = messageBody;
207+
} else if (messageBody) {
208+
body = messageBody.decodedData;
199209
}
200210

201-
// Allow optionally formatting non-JSON-records as JSON-records, if it looks like it might be
202-
if (!types.has('json-records') && isProbablyJsonRecords(body)) {
203-
types.add('json-records');
211+
// For common mistypings, we override the default type if we're confident
212+
const isCommonDefaultType = contentType === 'raw' || contentType === 'text' || contentType === 'html';
213+
const firstRealChar = getFirstRealChar(body);
214+
215+
// Format non-JSON-records as JSON-records, if it looks like it might be
216+
if (!availableTypes.has('json-records') && isProbablyJsonRecords(body)) {
217+
availableTypes.add('json-records');
218+
preferredType = 'json-records';
219+
220+
if (isCommonDefaultType) {
221+
preferredType = 'json-records';
222+
}
204223
}
205224

206-
if (!types.has('json-records') && isProbablyJson(body)) {
225+
if (!availableTypes.has('json-records') && isProbablyJson(body)) {
207226
// Allow optionally formatting non-JSON as JSON, if it's anything remotely close
208-
types.add('json');
209-
}
227+
availableTypes.add('json');
210228

211-
// Allow optionally formatting non-XML as XML, if it looks like it might be
212-
if (body?.subarray(0, 1).toString('ascii') === '<') {
213-
types.add('xml');
229+
if (isCommonDefaultType) {
230+
preferredType = 'json';
231+
}
214232
}
215233

216234
if (
217235
body &&
218-
!types.has('protobuf') &&
219-
!types.has('grpc-proto') &&
236+
!availableTypes.has('protobuf') &&
237+
!availableTypes.has('grpc-proto') &&
220238
isProbablyProtobuf(body) &&
221239
// If it's probably unmarked protobuf, and it's a manageable size, try
222240
// parsing it just to check:
223241
(body.length < 100_000 && isValidProtobuf(body))
224242
) {
225-
types.add('protobuf');
243+
availableTypes.add('protobuf');
244+
245+
if (isCommonDefaultType && body.length < 100_000) { // If we've checked fully
246+
preferredType = 'protobuf';
247+
}
226248
}
227249

228250
if (
229251
body &&
230-
!types.has('grpc-proto') &&
252+
!availableTypes.has('grpc-proto') &&
231253
isProbablyGrpcProto(body, headers ?? {}) &&
232254
// If it's probably unmarked gRPC, and it's a manageable size, try
233255
// parsing it just to check:
234256
(body.length < 100_000 && isValidGrpcProto(body, headers ?? {}))
235257
) {
236-
types.add('grpc-proto');
258+
availableTypes.add('grpc-proto');
259+
260+
if (isCommonDefaultType && body.length < 100_000) { // If we've checked fully
261+
preferredType = 'grpc-proto';
262+
}
237263
}
238264

239265
// SVGs can always be shown as XML
240266
if (rawContentType && rawContentType.startsWith('image/svg')) {
241-
types.add('xml');
267+
availableTypes.add('xml');
268+
}
269+
270+
// Allow optionally formatting non-XML as XML, if it looks like it might be but
271+
// isn't otherwise recognized as such:
272+
if (firstRealChar === '<') {
273+
if (!availableTypes.has('xml')) {
274+
availableTypes.add('xml');
275+
276+
if (contentType !== 'html' && isCommonDefaultType) {
277+
preferredType = 'xml';
278+
}
279+
280+
// Sniffed XML could also be HTML, as long as it's not an SVG:
281+
if (contentType !== 'image') {
282+
availableTypes.add('html');
283+
}
284+
}
285+
} else {
286+
// If it doesn't start with < then it's no normal HTML, no matter what it says.
287+
// Treat as text by default instead to avoid formatting issues:
288+
if (preferredType === 'html') {
289+
preferredType = 'text';
290+
}
242291
}
243292

244293
if (
245294
body &&
246-
!types.has('base64') &&
295+
!availableTypes.has('base64') &&
247296
body.length >= 8 &&
248-
// body.length % 4 === 0 && // Multiple of 4 bytes (final padding may be omitted)
297+
// body.length % 4 === 0 && // Multiple of 4 bytes (no - final padding may be omitted)
249298
body.length < 100_000 && // < 100 KB of content
250299
(body.every(isValidStandardBase64Byte) || body.every(isValidURLSafeBase64Byte))
251300
) {
252-
types.add('base64');
301+
availableTypes.add('base64');
253302
}
254303

255304
// Lastly, anything can be shown raw or as text, if you like:
256-
types.add('text');
257-
types.add('raw');
305+
availableTypes.add('text');
306+
availableTypes.add('raw');
258307

259-
return Array.from(types);
308+
return {
309+
preferredContentType: preferredType,
310+
availableContentTypes: Array.from(availableTypes)
311+
};
260312
}
313+
314+
function getFirstRealChar(buffer: Buffer | undefined): string | null {
315+
if (!buffer || buffer.length === 0) return null;
316+
317+
// Detect BOM, skip it if present
318+
let startOffset = 0;
319+
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
320+
startOffset = 3;
321+
}
322+
323+
// We ignore common whitespace - no need to worry about unicode edge cases etc:
324+
const firstNonWhitespaceByte = buffer
325+
.subarray(startOffset, startOffset + 1024)
326+
.find(byte =>
327+
byte !== 0x20 && // Space
328+
byte !== 0x09 && // Tab
329+
byte !== 0x0A && // LF
330+
byte !== 0x0D // CR
331+
);
332+
333+
return firstNonWhitespaceByte !== undefined
334+
? String.fromCharCode(firstNonWhitespaceByte)
335+
: null;
336+
}

0 commit comments

Comments
 (0)