From 151dc7a2c88833d4836c1e91b0503beff7a2faf0 Mon Sep 17 00:00:00 2001 From: Roman Khafizianov Date: Thu, 5 Jun 2025 19:37:31 +0200 Subject: [PATCH 1/2] feat: add Protocol Buffers Well-Known Types transformation to gRPC DevTools - Add Struct/Value checkbox to transform protobuf Well-Known Types - Transform Struct fieldsMap arrays to regular JSON objects - Unwrap Value types (stringValue, numberValue, etc.) to primitives - Convert ListValue to regular arrays - Transform Duration to human-readable format (e.g., "3500ms") - Transform FieldMask to comma-separated string - Add separate "Remove List" checkbox for removing 'List' suffix from arrays - Both transformations work in Preview and raw JSON modes - Settings persist across sessions --- .../grpc/src/contexts/detail-context.tsx | 22 + devtools/panels/grpc/src/entities/detail.ts | 2 + .../__tests__/transform-well-known-types.ts | 390 ++++++++++++++++++ .../src/helper/transform-well-known-types.ts | 217 ++++++++++ .../grpc/src/reducers/detail-reducer.ts | 22 + .../tab-group/tab-panels/TabPanelMessages.tsx | 49 ++- 6 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 devtools/panels/grpc/src/helper/__tests__/transform-well-known-types.ts create mode 100644 devtools/panels/grpc/src/helper/transform-well-known-types.ts diff --git a/devtools/panels/grpc/src/contexts/detail-context.tsx b/devtools/panels/grpc/src/contexts/detail-context.tsx index a403021d..12c23f53 100644 --- a/devtools/panels/grpc/src/contexts/detail-context.tsx +++ b/devtools/panels/grpc/src/contexts/detail-context.tsx @@ -23,6 +23,28 @@ export const DetailProvider = ({ children }: { children?: JSX.Element }) => { dispatch({ type: "toggleIsISO8601" }); }, ); + usePersistReducerState( + detail, + [ + "messages", + "isStructValue", + ], + (isStructValue: boolean) => { + if (isStructValue === detail.messages.isStructValue) return; + dispatch({ type: "toggleIsStructValue" }); + }, + ); + usePersistReducerState( + detail, + [ + "messages", + "isRemoveListSuffix", + ], + (isRemoveListSuffix: boolean) => { + if (isRemoveListSuffix === detail.messages.isRemoveListSuffix) return; + dispatch({ type: "toggleIsRemoveListSuffix" }); + }, + ); usePersistReducerState( detail, [ diff --git a/devtools/panels/grpc/src/entities/detail.ts b/devtools/panels/grpc/src/entities/detail.ts index c0471381..27a4641e 100644 --- a/devtools/panels/grpc/src/entities/detail.ts +++ b/devtools/panels/grpc/src/entities/detail.ts @@ -5,6 +5,8 @@ export type Detail = { focusedIndex: null | number; isPreview: boolean; isISO8601: boolean; + isStructValue: boolean; + isRemoveListSuffix: boolean; isStickyScroll: boolean; }; }; diff --git a/devtools/panels/grpc/src/helper/__tests__/transform-well-known-types.ts b/devtools/panels/grpc/src/helper/__tests__/transform-well-known-types.ts new file mode 100644 index 00000000..350018a0 --- /dev/null +++ b/devtools/panels/grpc/src/helper/__tests__/transform-well-known-types.ts @@ -0,0 +1,390 @@ +import { transformWellKnownTypes } from '../transform-well-known-types'; + +describe('transformWellKnownTypes', () => { + it('should transform Struct with fieldsMap to regular object', () => { + const input = { + fieldsMap: [ + ['key1', { kind: 'stringValue', stringValue: 'value1' }], + ['key2', { kind: 'numberValue', numberValue: 42 }], + ['key3', { kind: 'boolValue', boolValue: true }], + ['key4', { kind: 'nullValue', nullValue: 0 }], + ] + }; + + const expected = { + key1: 'value1', + key2: 42, + key3: true, + key4: null, + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should transform nested Struct values', () => { + const input = { + fieldsMap: [ + ['nested', { + kind: 'structValue', + structValue: { + fieldsMap: [ + ['innerKey', { kind: 'stringValue', stringValue: 'innerValue' }] + ] + } + }], + ['list', { + kind: 'listValue', + listValue: { + valuesList: [ + { kind: 'numberValue', numberValue: 1 }, + { kind: 'numberValue', numberValue: 2 }, + { kind: 'numberValue', numberValue: 3 } + ] + } + }] + ] + }; + + const expected = { + nested: { + innerKey: 'innerValue' + }, + list: [1, 2, 3] + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should transform Value objects', () => { + const stringValue = { kind: 'stringValue', stringValue: 'hello' }; + const numberValue = { kind: 'numberValue', numberValue: 123 }; + const boolValue = { kind: 'boolValue', boolValue: false }; + const nullValue = { kind: 'nullValue', nullValue: 0 }; + + expect(transformWellKnownTypes(stringValue)).toBe('hello'); + expect(transformWellKnownTypes(numberValue)).toBe(123); + expect(transformWellKnownTypes(boolValue)).toBe(false); + expect(transformWellKnownTypes(nullValue)).toBe(null); + }); + + it('should transform ListValue', () => { + const input = { + valuesList: [ + { kind: 'stringValue', stringValue: 'a' }, + { kind: 'numberValue', numberValue: 1 }, + { kind: 'boolValue', boolValue: true } + ] + }; + + const expected = ['a', 1, true]; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should transform Duration to milliseconds string', () => { + const input = { + seconds: '3', + nanos: 500000000 + }; + + expect(transformWellKnownTypes(input)).toBe('3500ms'); + }); + + it('should transform FieldMask to comma-separated string', () => { + const input = { + paths: ['field1', 'field2', 'field3'] + }; + + expect(transformWellKnownTypes(input)).toBe('field1,field2,field3'); + }); + + it('should handle Any type', () => { + const input = { + typeUrl: 'type.googleapis.com/google.protobuf.StringValue', + value: { value: 'test' } + }; + + const expected = { + '@type': 'type.googleapis.com/google.protobuf.StringValue', + value: 'test' + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should recursively transform nested objects', () => { + const input = { + normal: 'value', + struct: { + fieldsMap: [ + ['key', { kind: 'stringValue', stringValue: 'value' }] + ] + }, + array: [ + { fieldsMap: [['item', { kind: 'numberValue', numberValue: 1 }]] }, + { normal: 'item' } + ] + }; + + const expected = { + normal: 'value', + struct: { + key: 'value' + }, + array: [ + { item: 1 }, + { normal: 'item' } + ] + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should handle null and undefined', () => { + expect(transformWellKnownTypes(null)).toBe(null); + expect(transformWellKnownTypes(undefined)).toBe(undefined); + }); + + it('should handle primitive values', () => { + expect(transformWellKnownTypes('string')).toBe('string'); + expect(transformWellKnownTypes(123)).toBe(123); + expect(transformWellKnownTypes(true)).toBe(true); + }); + + it('should transform wrapper Value objects', () => { + const input = { + usersList: [ + { + name: { stringValue: 'Alice' }, + active: { boolValue: true }, + age: { numberValue: 30 }, + metadata: { nullValue: 0 }, + nested: { + structValue: { + fieldsMap: [ + ['key', { kind: 'stringValue', stringValue: 'value' }] + ] + } + } + } + ] + }; + + const expected = { + usersList: [ + { + name: 'Alice', + active: true, + age: 30, + metadata: null, + nested: { + key: 'value' + } + } + ] + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should handle mixed wrapper and non-wrapper formats', () => { + const input = { + // Wrapper format + name: { stringValue: 'test' }, + // Regular value + normalField: 'normal', + // Nested wrapper + details: { + enabled: { boolValue: false }, + count: { numberValue: 10 } + } + }; + + const expected = { + name: 'test', + normalField: 'normal', + details: { + enabled: false, + count: 10 + } + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should handle listValue with nested valuesList structure', () => { + const input = { + itemsList: [ + { + timestamp: { numberValue: 1234567890 }, + title: { stringValue: 'Example' }, + tags: { + listValue: { + valuesList: [ + { stringValue: 'tag1' }, + { stringValue: 'tag2' }, + { numberValue: 42 } + ] + } + }, + description: { stringValue: 'A sample item' }, + priority: { numberValue: 1 } + } + ] + }; + + const expected = { + itemsList: [ + { + timestamp: 1234567890, + title: 'Example', + tags: ['tag1', 'tag2', 42], + description: 'A sample item', + priority: 1 + } + ] + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should handle complex nested structures with all Well-Known Types', () => { + const input = { + metadata: { + count: 100, + page: 1 + }, + itemsList: [], + entitiesList: [ + { + id: { stringValue: "entity-123" }, + enabled: { boolValue: false }, + readonly: { boolValue: true }, + type: { numberValue: 1 }, + label: { stringValue: "Sample Entity" }, + defaultValue: { nullValue: 0 }, + format: { numberValue: 0 }, + allowedTypes: { + listValue: { + valuesList: [] + } + }, + settings: { + structValue: { + fieldsMap: [ + ['theme', { stringValue: 'dark' }], + ['fontSize', { numberValue: 14 }] + ] + } + } + } + ] + }; + + const expected = { + metadata: { + count: 100, + page: 1 + }, + itemsList: [], + entitiesList: [ + { + id: "entity-123", + enabled: false, + readonly: true, + type: 1, + label: "Sample Entity", + defaultValue: null, + format: 0, + allowedTypes: [], + settings: { + theme: 'dark', + fontSize: 14 + } + } + ] + }; + + const result = transformWellKnownTypes(input); + expect(result).toEqual(expected); + }); + + it('should handle fieldsMap with wrapper values instead of StructValues', () => { + const input = { + fieldsMap: [ + ['field1', { stringValue: 'text' }], + ['field2', { numberValue: 100 }], + ['field3', { boolValue: false }], + ['field4', { nullValue: 0 }], + ['field5', { + listValue: { + valuesList: [ + { stringValue: 'a' }, + { numberValue: 1 }, + { boolValue: true } + ] + } + }] + ] + }; + + const expected = { + field1: 'text', + field2: 100, + field3: false, + field4: null, + field5: ['a', 1, true] + }; + + expect(transformWellKnownTypes(input)).toEqual(expected); + }); + + it('should remove List suffix from arrays when option is enabled', () => { + const input = { + itemsList: ['a', 'b', 'c'], + usersList: [ + { name: { stringValue: 'John' } } + ] + }; + + const withRemoveList = transformWellKnownTypes(input, { removeListSuffix: true }); + expect(withRemoveList).toEqual({ + items: ['a', 'b', 'c'], + users: [ + { name: 'John' } + ] + }); + + const withoutRemoveList = transformWellKnownTypes(input, { removeListSuffix: false }); + expect(withoutRemoveList).toEqual({ + itemsList: ['a', 'b', 'c'], + usersList: [ + { name: 'John' } + ] + }); + }); + + it('should only remove List suffix from arrays, not other fields', () => { + const input = { + itemsList: ['a', 'b', 'c'], // Should become 'items' + todoList: 'My shopping list', // Should stay 'todoList' (not an array) + tasksList: [], // Should become 'tasks' + checklist: { item: 'value' }, // Should stay 'checklist' (not ending with 'List') + usersList: [ // Should become 'users' + { name: { stringValue: 'John' } } + ] + }; + + const expected = { + items: ['a', 'b', 'c'], + todoList: 'My shopping list', + tasks: [], + checklist: { item: 'value' }, + users: [ + { name: 'John' } + ] + }; + + expect(transformWellKnownTypes(input, { removeListSuffix: true })).toEqual(expected); + }); +}); \ No newline at end of file diff --git a/devtools/panels/grpc/src/helper/transform-well-known-types.ts b/devtools/panels/grpc/src/helper/transform-well-known-types.ts new file mode 100644 index 00000000..e888533b --- /dev/null +++ b/devtools/panels/grpc/src/helper/transform-well-known-types.ts @@ -0,0 +1,217 @@ +/** + * Transform Protocol Buffers Well-Known Types to more readable formats + */ + +interface StructValue { + kind?: string; + structValue?: { fieldsMap?: Array<[string, StructValue]> }; + listValue?: { valuesList?: StructValue[] }; + numberValue?: number; + stringValue?: string; + boolValue?: boolean; + nullValue?: 0; +} + +interface WrapperValue { + stringValue?: string; + numberValue?: number; + boolValue?: boolean; + nullValue?: 0; + structValue?: any; + listValue?: { valuesList?: any[] }; +} + +interface TransformableObject { + [key: string]: any; +} + +/** + * Transform a Struct's fieldsMap array to a regular object + */ +function transformStructFieldsMap(fieldsMap: Array<[string, any]>, options?: { removeListSuffix?: boolean }): Record { + const result: Record = {}; + + for (const [key, value] of fieldsMap) { + // First try to transform as a StructValue, then as a general value + if (value && typeof value === 'object' && value.kind) { + result[key] = transformStructValue(value, options); + } else { + // This might be a wrapper value or other Well-Known Type + result[key] = transformWellKnownTypes(value, options); + } + } + + return result; +} + +/** + * Transform a Struct Value to its actual JavaScript value + */ +function transformStructValue(value: StructValue, options?: { removeListSuffix?: boolean }): any { + if (!value || typeof value !== 'object') { + return value; + } + + // Check if this is actually a Struct Value (has a 'kind' field) + if (!value.kind) { + // Not a struct value, return as-is + return value; + } + + switch (value.kind) { + case 'structValue': + if (value.structValue?.fieldsMap) { + return transformStructFieldsMap(value.structValue.fieldsMap, options); + } + return {}; + + case 'listValue': + if (value.listValue?.valuesList) { + return value.listValue.valuesList.map((v: any) => transformStructValue(v, options)); + } + return []; + + case 'numberValue': + return value.numberValue ?? 0; + + case 'stringValue': + return value.stringValue ?? ''; + + case 'boolValue': + return value.boolValue ?? false; + + case 'nullValue': + return null; + + default: + return null; + } +} + +/** + * Recursively transform Well-Known Types in an object + */ +export function transformWellKnownTypes(obj: any, options?: { removeListSuffix?: boolean }): any { + if (obj === null || obj === undefined) { + return obj; + } + + // Handle arrays + if (Array.isArray(obj)) { + return obj.map(item => transformWellKnownTypes(item, options)); + } + + // Handle non-objects + if (typeof obj !== 'object') { + return obj; + } + + // Check if this is a Struct with fieldsMap + if (obj.fieldsMap && Array.isArray(obj.fieldsMap)) { + // Ensure this looks like a proper Struct (fieldsMap contains [key, value] pairs) + if (obj.fieldsMap.length > 0 && Array.isArray(obj.fieldsMap[0]) && obj.fieldsMap[0].length === 2) { + return transformStructFieldsMap(obj.fieldsMap, options); + } + } + + // Check if this is a Value object (has specific kind values) + const validKinds = ['structValue', 'listValue', 'numberValue', 'stringValue', 'boolValue', 'nullValue']; + if (obj.kind && typeof obj.kind === 'string' && validKinds.includes(obj.kind)) { + return transformStructValue(obj, options); + } + + // Check if this is a wrapper Value + const wrapperKeys = ['stringValue', 'numberValue', 'boolValue', 'structValue', 'listValue', 'nullValue']; + const objKeys = Object.keys(obj); + + // Check if ALL keys are wrapper keys (could be multiple due to oneOf implementation) + const hasOnlyWrapperKeys = objKeys.length > 0 && objKeys.every(key => wrapperKeys.includes(key)); + + if (hasOnlyWrapperKeys) { + // Handle the most common case: single wrapper key + if (objKeys.length === 1) { + const key = objKeys[0]; + switch (key) { + case 'stringValue': + return obj.stringValue; + case 'numberValue': + return obj.numberValue; + case 'boolValue': + return obj.boolValue; + case 'nullValue': + return null; + case 'structValue': + return transformWellKnownTypes(obj.structValue, options); + case 'listValue': + // Handle nested listValue structure + if (obj.listValue && obj.listValue.valuesList) { + return obj.listValue.valuesList.map((v: any) => transformWellKnownTypes(v, options)); + } + return transformWellKnownTypes(obj.listValue, options); + } + } else { + // Multiple wrapper keys - take the first defined one + for (const key of wrapperKeys) { + if (key in obj && obj[key] !== undefined) { + switch (key) { + case 'stringValue': + return obj.stringValue; + case 'numberValue': + return obj.numberValue; + case 'boolValue': + return obj.boolValue; + case 'nullValue': + return null; + case 'structValue': + return transformWellKnownTypes(obj.structValue, options); + case 'listValue': + if (obj.listValue && obj.listValue.valuesList) { + return obj.listValue.valuesList.map((v: any) => transformWellKnownTypes(v, options)); + } + return transformWellKnownTypes(obj.listValue, options); + } + } + } + } + } + + // Check if this is a ListValue + if (obj.valuesList && Array.isArray(obj.valuesList)) { + return obj.valuesList.map((v: any) => transformStructValue(v, options)); + } + + // Check if this is an Any type + if (obj.typeUrl && obj.value) { + // For now, just return the object as-is, but mark it with the type URL + return { + '@type': obj.typeUrl, + ...transformWellKnownTypes(obj.value, options) + }; + } + + // Check if this is a Duration + if (obj.seconds !== undefined && obj.nanos !== undefined && Object.keys(obj).length === 2) { + const seconds = Number(obj.seconds) || 0; + const nanos = Number(obj.nanos) || 0; + const totalMs = seconds * 1000 + nanos / 1000000; + return `${totalMs}ms`; + } + + // Check if this is a FieldMask + if (obj.paths && Array.isArray(obj.paths)) { + return obj.paths.join(','); + } + + // Recursively transform nested objects + const result: TransformableObject = {}; + for (const [key, value] of Object.entries(obj)) { + // Remove 'List' suffix from field names if option is enabled + const transformedKey = options?.removeListSuffix && key.endsWith('List') && Array.isArray(value) + ? key.slice(0, -4) + : key; + + result[transformedKey] = transformWellKnownTypes(value, options); + } + + return result; +} \ No newline at end of file diff --git a/devtools/panels/grpc/src/reducers/detail-reducer.ts b/devtools/panels/grpc/src/reducers/detail-reducer.ts index 6d5491ee..3438bee6 100644 --- a/devtools/panels/grpc/src/reducers/detail-reducer.ts +++ b/devtools/panels/grpc/src/reducers/detail-reducer.ts @@ -10,6 +10,8 @@ export type DetailAction = | { type: "closedMessage" } | { type: "toggleIsPreview" } | { type: "toggleIsISO8601" } + | { type: "toggleIsStructValue" } + | { type: "toggleIsRemoveListSuffix" } | { type: "toggleIsStickyScroll" }; export const initialDetail: Detail = { @@ -19,6 +21,8 @@ export const initialDetail: Detail = { focusedIndex: null, isPreview: true, isISO8601: true, + isStructValue: true, + isRemoveListSuffix: true, isStickyScroll: true, }, }; @@ -79,6 +83,24 @@ export const detailReducer: Reducer, DetailAction> = (state }, }; + case "toggleIsStructValue": + return { + ...state, + messages: { + ...state.messages, + isStructValue: !state.messages.isStructValue, + }, + }; + + case "toggleIsRemoveListSuffix": + return { + ...state, + messages: { + ...state.messages, + isRemoveListSuffix: !state.messages.isRemoveListSuffix, + }, + }; + case "toggleIsStickyScroll": return { ...state, diff --git a/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx b/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx index 352cccbc..dfafb337 100644 --- a/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx +++ b/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx @@ -8,6 +8,7 @@ import { useDetail, useDetailDispatch } from "@/contexts/detail-context"; import { useRequestRowsDispatch } from "@/contexts/request-rows-context"; import { stringifyPreview } from "@/helper/stringify-preview"; import { transformTimestampLikeObjectToISO8601 } from "@/helper/transform-timestamp-like-object-to-iso8601"; +import { transformWellKnownTypes } from "@/helper/transform-well-known-types"; import { useDetailMessagesFocusedIndex } from "@/hooks/use-detail-messages-focused-index"; import useRequestRow from "@/hooks/use-request-row"; import { isEOFMessage } from "@/interactors/is-eof-message"; @@ -95,9 +96,23 @@ const TabPanelMessages = ({ isFocusIn: _isFocusIn }: { isFocusIn: boolean }) => const object = focusedMessage ? isEOFMessage(focusedMessage) ? "EOF" - : detail.messages.isISO8601 - ? JSON.parse(stringify(transformTimestampLikeObjectToISO8601(focusedMessage.data))) - : JSON.parse(stringify(focusedMessage.data)) + : (() => { + // Clone the data first to avoid any immutability issues + let data = JSON.parse(stringify(focusedMessage.data)); + + // Apply transformations based on checkboxes + if (detail.messages.isISO8601) { + data = transformTimestampLikeObjectToISO8601(data); + } + + if (detail.messages.isStructValue) { + data = transformWellKnownTypes(data, { + removeListSuffix: detail.messages.isRemoveListSuffix + }); + } + + return data; + })() : ""; const renderItem = (index: number) => { @@ -343,6 +358,34 @@ const TabPanelMessages = ({ isFocusIn: _isFocusIn }: { isFocusIn: boolean }) => ISO 8601 +
+ { + detailDispatch({ type: "toggleIsStructValue" }); + }} + > + Struct/Value + +
+ {detail.messages.isStructValue && ( +
+ { + detailDispatch({ type: "toggleIsRemoveListSuffix" }); + }} + > + Remove List + +
+ )} {detail.messages.isPreview && ( Date: Thu, 5 Jun 2025 19:48:17 +0200 Subject: [PATCH 2/2] rename toggle to Trim List Suffix --- .../request-detail/tab-group/tab-panels/TabPanelMessages.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx b/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx index dfafb337..a7db1daf 100644 --- a/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx +++ b/devtools/panels/grpc/src/views/main/request-detail/tab-group/tab-panels/TabPanelMessages.tsx @@ -374,7 +374,7 @@ const TabPanelMessages = ({ isFocusIn: _isFocusIn }: { isFocusIn: boolean }) => {detail.messages.isStructValue && (
detailDispatch({ type: "toggleIsRemoveListSuffix" }); }} > - Remove List + Trim List Suffix
)}