Skip to content

Commit 1cfcda6

Browse files
authored
Merge pull request #126 from bluemods/main
Update Protobuf Content Types
2 parents e187bf7 + b896ddf commit 1cfcda6

File tree

5 files changed

+95
-7
lines changed

5 files changed

+95
-7
lines changed

src/model/events/body-formatting.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ export const Formatters: { [key in ViewableContentType]: Formatter } = {
142142
isEditApplicable: false,
143143
render: buildAsyncRenderer('protobuf')
144144
},
145+
'grpc-proto': {
146+
language: 'protobuf',
147+
cacheKey: Symbol('grpc-proto'),
148+
isEditApplicable: false,
149+
render: buildAsyncRenderer('grpc-proto')
150+
},
145151
'url-encoded': {
146152
layout: 'scrollable',
147153
Component: styled(ReadOnlyParams).attrs((p: FormatComponentProps) => ({

src/model/events/content-types.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,32 @@ import {
88
// Simplify a mime type as much as we can, without throwing any errors
99
export const getBaseContentType = (mimeType: string | undefined) => {
1010
const typeWithoutParams = (mimeType || '').split(';')[0];
11-
const [type, combinedSubTypes] = typeWithoutParams.split(/\/(.+)/);
1211

12+
let [type, combinedSubTypes] = typeWithoutParams.split(/\/(.+)/);
1313
if (!combinedSubTypes) return type;
1414

15-
// A list of types from most specific to most generic: [svg, xml] for image/svg+xml
16-
const subTypes = combinedSubTypes.split('+');
15+
if (DEFAULT_SUBTYPE[combinedSubTypes]) {
16+
combinedSubTypes = `${combinedSubTypes}+${DEFAULT_SUBTYPE[combinedSubTypes]}`;
17+
}
18+
19+
// If this is a known type with an exact match, return that directly:
20+
if (mimeTypeToContentTypeMap[type + '/' + combinedSubTypes]) {
21+
return type + '/' + combinedSubTypes;
22+
}
1723

24+
// Otherwise, wr collect a list of types from most specific to most generic: [svg, xml] for image/svg+xml
25+
// and then look through in order to see if there are any matches here:
26+
const subTypes = combinedSubTypes.split('+');
1827
const possibleTypes = subTypes.map(st => type + '/' + st);
19-
return _.find(possibleTypes, t => !!mimeTypeToContentTypeMap[t]) ||
28+
29+
return _.find(possibleTypes, t => !!mimeTypeToContentTypeMap[t]) || // Subtype match
2030
_.last(possibleTypes)!; // If we recognize none - return the most generic
2131
}
2232

33+
const DEFAULT_SUBTYPE: { [type: string]: string } = {
34+
'grpc': 'proto' // Protobuf is the default gRPC content type (but not the only one!)
35+
};
36+
2337
export type ViewableContentType =
2438
| 'raw'
2539
| 'text'
@@ -33,7 +47,8 @@ export type ViewableContentType =
3347
| 'markdown'
3448
| 'yaml'
3549
| 'image'
36-
| 'protobuf';
50+
| 'protobuf'
51+
| 'grpc-proto';
3752

3853
export const EditableContentTypes = [
3954
'text',
@@ -94,6 +109,9 @@ const mimeTypeToContentTypeMap: { [mimeType: string]: ViewableContentType } = {
94109
'application/vnd.google.protobuf': 'protobuf',
95110
'application/x-google-protobuf': 'protobuf',
96111
'application/proto': 'protobuf', // N.b. this covers all application/XXX+proto values
112+
'application/x-protobuffer': 'protobuf', // Commonly seen in Google apps
113+
114+
'application/grpc+proto': 'grpc-proto', // Used in GRPC requests (protobuf but with special headers)
97115

98116
'application/octet-stream': 'raw'
99117
} as const;
@@ -120,6 +138,7 @@ export function getContentEditorName(contentType: ViewableContentType): string {
120138
: contentType === 'json' ? 'JSON'
121139
: contentType === 'css' ? 'CSS'
122140
: contentType === 'url-encoded' ? 'URL-Encoded'
141+
: contentType === 'grpc-proto' ? 'gRPC'
123142
: _.capitalize(contentType);
124143
}
125144

@@ -161,10 +180,15 @@ export function getCompatibleTypes(
161180
types.add('xml');
162181
}
163182

183+
if (!types.has('grpc-proto') && rawContentType === 'application/grpc') {
184+
types.add('grpc-proto')
185+
}
186+
164187
if (
165188
body &&
166189
isProbablyProtobuf(body) &&
167190
!types.has('protobuf') &&
191+
!types.has('grpc-proto') &&
168192
// If it's probably unmarked protobuf, and it's a manageable size, try
169193
// parsing it just to check:
170194
(body.length < 100_000 && isValidProtobuf(body))
@@ -192,4 +216,4 @@ export function getCompatibleTypes(
192216
types.add('raw');
193217

194218
return Array.from(types);
195-
}
219+
}

src/services/ui-worker-formatters.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import * as beautifyXml from 'xml-beautifier';
77

88
import { bufferToHex, bufferToString, getReadableSize } from '../util/buffer';
9-
import { parseRawProtobuf } from '../util/protobuf';
9+
import { parseRawProtobuf, extractProtobufFromGrpc } from '../util/protobuf';
1010

1111
const truncationMarker = (size: string) => `\n[-- Truncated to ${size} --]`;
1212
const FIVE_MB = 1024 * 1024 * 5;
@@ -78,6 +78,27 @@ const WorkerFormatters = {
7878
prefix: ''
7979
});
8080

81+
return JSON.stringify(data, (_key, value) => {
82+
// Buffers have toJSON defined, so arrive here in JSONified form:
83+
if (value.type === 'Buffer' && Array.isArray(value.data)) {
84+
const buffer = Buffer.from(value.data);
85+
86+
return {
87+
"Type": `Buffer (${getReadableSize(buffer)})`,
88+
"As string": bufferToString(buffer, 'detect-encoding'),
89+
"As hex": bufferToHex(buffer)
90+
}
91+
} else {
92+
return value;
93+
}
94+
}, 2);
95+
},
96+
'grpc-proto': (content: Buffer) => {
97+
const protobufMessages = extractProtobufFromGrpc(content);
98+
99+
let data = protobufMessages.map((msg) => parseRawProtobuf(msg, { prefix: '' }));
100+
if (data.length === 1) data = data[0];
101+
81102
return JSON.stringify(data, (_key, value) => {
82103
// Buffers have toJSON defined, so arrive here in JSONified form:
83104
if (value.type === 'Buffer' && Array.isArray(value.data)) {

src/util/protobuf.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ export function isProbablyProtobuf(input: Uint8Array) {
2828

2929
export const parseRawProtobuf = parseRawProto;
3030

31+
// GRPC message structure:
32+
// The repeated sequence of Length-Prefixed-Message items is delivered in DATA frames
33+
// Length-Prefixed-Message → Compressed-Flag Message-Length Message
34+
// Compressed-Flag → 0 / 1 ; encoded as 1 byte unsigned integer
35+
// Message-Length → {length of Message} ; encoded as 4 byte unsigned integer (big endian)
36+
// Message → *{binary octet}
37+
export const extractProtobufFromGrpc = (input: Buffer) => {
38+
const protobufMessasges: Buffer[] = [];
39+
40+
while (input.length > 0) {
41+
if (input.readInt8() != 0) {
42+
throw new Error("Compressed gRPC messages not yet supported")
43+
}
44+
45+
const length = input.readInt32BE(1);
46+
protobufMessasges.push(input.slice(5, 5 + length));
47+
input = input.subarray(5 + length);
48+
}
49+
50+
return protobufMessasges;
51+
}
52+
3153
export const isValidProtobuf = (input: Uint8Array) => {
3254
try {
3355
parseRawProtobuf(input);

test/unit/model/http/content-types.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ describe('Content type parsing', () => {
4949
expect(ct).to.equal('raw');
5050
});
5151

52+
it('should render application/grpc as protobuf grpc', () => {
53+
const ct = getContentType('application/grpc');
54+
expect(ct).to.equal('grpc-proto');
55+
});
56+
57+
it('should render application/grpc+proto as protobuf grpc', () => {
58+
const ct = getContentType('application/grpc+proto');
59+
expect(ct).to.equal('grpc-proto');
60+
});
61+
62+
it('should render application/grpc+json as JSON', () => {
63+
const ct = getContentType('application/grpc+json');
64+
expect(ct).to.equal('json');
65+
});
66+
5267
it('should return undefined for unknown content', () => {
5368
const ct = getContentType('application/unknownsomething');
5469
expect(ct).to.equal(undefined);

0 commit comments

Comments
 (0)