From 97760348e3afe5dfd94686df6858f6839e4d1115 Mon Sep 17 00:00:00 2001 From: Nick Graham Date: Tue, 1 Aug 2023 03:53:17 -0500 Subject: [PATCH] feat: gRPC request stubs (#6196) * feat: grpc request stubs * mark changed lines * add tests * fix infinite recursion * replace empty body * catch Type objects * button to replace stubs * flatten grpc editor * remove grpc-editor * trigger setvalue * rename mocks to example --------- Co-authored-by: jackkav Co-authored-by: James Gatz --- .../src/main/ipc/__tests__/automock.test.ts | 122 ++++++++ .../src/main/ipc/__tests__/grpc.test.ts | 12 + packages/insomnia/src/main/ipc/automock.ts | 261 ++++++++++++++++++ packages/insomnia/src/main/ipc/grpc.ts | 27 +- .../src/ui/components/editors/grpc-editor.tsx | 51 ---- .../ui/components/panes/grpc-request-pane.tsx | 119 +++++--- .../components/panes/grpc-response-pane.tsx | 46 ++- .../viewers/grpc-tabbed-messages.tsx | 93 ------- 8 files changed, 522 insertions(+), 209 deletions(-) create mode 100644 packages/insomnia/src/main/ipc/__tests__/automock.test.ts create mode 100644 packages/insomnia/src/main/ipc/automock.ts delete mode 100644 packages/insomnia/src/ui/components/editors/grpc-editor.tsx delete mode 100644 packages/insomnia/src/ui/components/viewers/grpc-tabbed-messages.tsx diff --git a/packages/insomnia/src/main/ipc/__tests__/automock.test.ts b/packages/insomnia/src/main/ipc/__tests__/automock.test.ts new file mode 100644 index 000000000..bd141ddad --- /dev/null +++ b/packages/insomnia/src/main/ipc/__tests__/automock.test.ts @@ -0,0 +1,122 @@ +import { it } from '@jest/globals'; +import { parse } from 'protobufjs'; + +import { mockRequestMethods } from '../automock'; + +it('mocks simple requests', () => { + const parsed = parse(` + syntax = "proto3"; + + message FooRequest { + string foo = 1; + } + + message FooResponse { + string foo = 1; + } + + service FooService { + rpc Foo (FooRequest) returns (FooResponse); + }`); + + const service = parsed.root.lookupService('FooService'); + const mocked = mockRequestMethods(service); + + const plain = mocked['Foo']().plain; + expect(plain).toStrictEqual({ + foo: 'Hello', + }); +}); + +it('mocks requests with nested objects', () => { + const parsed = parse(` + syntax = "proto3"; + + message BarBarObject { + int32 one = 1; + } + + message BarObject { + BarBarObject fuzz = 1; + } + + message FooRequest { + BarObject bar = 2; + } + + message FooResponse { + string foo = 1; + } + + service FooService { + rpc Foo (FooRequest) returns (FooResponse); + }`); + + const service = parsed.root.lookupService('FooService'); + const mocked = mockRequestMethods(service); + + const plain = mocked['Foo']().plain; + expect(plain).toStrictEqual({ + bar: { + fuzz: { + one: 10, + }, + }, + }); +}); + +it('mocks requests with enums', () => { + const parsed = parse(` + syntax = "proto3"; + + enum MyEnum { + MYENUM_UNSPECIFIED = 0; + MYENUM_A = 1; + MYENUM_B = 2; + } + + message FooRequest { + MyEnum enum = 1; + } + + message FooResponse { + string foo = 1; + } + + service FooService { + rpc Foo (FooRequest) returns (FooResponse); + }`); + + const service = parsed.root.lookupService('FooService'); + const mocked = mockRequestMethods(service); + + const plain = mocked['Foo']().plain; + expect(plain).toStrictEqual({ + enum: 0, + }); +}); + +it('mocks requests with repeated values', () => { + const parsed = parse(` + syntax = "proto3"; + + message FooRequest { + repeated string foo = 1; + } + + message FooResponse { + string foo = 1; + } + + service FooService { + rpc Foo (FooRequest) returns (FooResponse); + }`); + + const service = parsed.root.lookupService('FooService'); + const mocked = mockRequestMethods(service); + + const plain = mocked['Foo']().plain; + expect(plain).toStrictEqual({ + foo: ['Hello'], + }); +}); diff --git a/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts b/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts index 8dce69af6..db5fd052b 100644 --- a/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts +++ b/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts @@ -41,6 +41,9 @@ describe('loadMethodsFromReflection', () => { expect(methods).toStrictEqual([{ type: 'unary', fullPath: '/FooService/Foo', + example: { + foo: 'Hello', + }, }]); }); }); @@ -76,6 +79,9 @@ describe('loadMethodsFromReflection', () => { expect(methods).toStrictEqual([{ type: 'unary', fullPath: '/FooService/format', + example: { + foo: 'Hello', + }, }]); }); }); @@ -123,9 +129,15 @@ describe('loadMethodsFromReflection', () => { expect(methods).toStrictEqual([{ type: 'unary', fullPath: '/FooService/Foo', + example: { + foo: 'Hello', + }, }, { type: 'unary', fullPath: '/BarService/Bar', + example: { + bar: 'Hello', + }, }]); }); }); diff --git a/packages/insomnia/src/main/ipc/automock.ts b/packages/insomnia/src/main/ipc/automock.ts new file mode 100644 index 000000000..2e71c8979 --- /dev/null +++ b/packages/insomnia/src/main/ipc/automock.ts @@ -0,0 +1,261 @@ +// From https://github.com/bloomrpc/bloomrpc-mock/blob/master/src/automock.ts +// TODO simplify this and rename to generate example payload +import { Enum, Field, MapField, Message, OneOf, Service, Type } from 'protobufjs'; +import { v4 } from 'uuid'; + +export interface MethodPayload { + plain: {[key: string]: any}; + message: Message; +} + +export interface ServiceMethodsPayload { + [name: string]: () => MethodPayload; +} + +const enum MethodType { + request, + response +} + +/** + * Mock method response + */ +export function mockResponseMethods( + service: Service, + mocks?: void | {}, +) { + return mockMethodReturnType( + service, + MethodType.response, + mocks + ); +} + +/** + * Mock methods request + */ +export function mockRequestMethods( + service: Service, + mocks?: void | {}, +) { + return mockMethodReturnType( + service, + MethodType.request, + mocks + ); +} + +function mockMethodReturnType( + service: Service, + type: MethodType, + mocks?: void | {}, +): ServiceMethodsPayload { + const root = service.root; + const serviceMethods = service.methods; + + return Object.keys(serviceMethods).reduce((methods: ServiceMethodsPayload, method: string) => { + const serviceMethod = serviceMethods[method]; + + const methodMessageType = type === MethodType.request + ? serviceMethod.requestType + : serviceMethod.responseType; + + const messageType = root.lookupType(methodMessageType); + + methods[method] = () => { + let data = {}; + if (!mocks) { + data = mockTypeFields(messageType, new StackDepth()); + } + return { plain: data, message: messageType.fromObject(data) }; + }; + + return methods; + }, {}); +} + +/** + * Mock a field type + */ +function mockTypeFields(type: Type, stackDepth: StackDepth): object { + if (stackDepth.incrementAndCheckIfOverMax(`$type.${type.name}`)) { + return {}; + } + + const fieldsData: { [key: string]: any } = {}; + if (!type.fieldsArray) { + return fieldsData; + } + return type.fieldsArray.reduce((data, field) => { + const resolvedField = field.resolve(); + + if (resolvedField.parent !== resolvedField.resolvedType) { + if (resolvedField.repeated) { + data[resolvedField.name] = [mockField(resolvedField, stackDepth)]; + } else { + data[resolvedField.name] = mockField(resolvedField, stackDepth); + } + } + + return data; + }, fieldsData); +} + +/** + * Mock enum + */ +function mockEnum(enumType: Enum): number { + const enumKey = Object.keys(enumType.values)[0]; + + return enumType.values[enumKey]; +} + +/** + * Mock a field + */ +function mockField(field: Field, stackDepth: StackDepth): any { + if (stackDepth.incrementAndCheckIfOverMax(`$field.${field.name}`)) { + return {}; + } + + if (field instanceof MapField) { + return mockMapField(field, stackDepth); + } + + if (field.resolvedType instanceof Enum) { + return mockEnum(field.resolvedType); + } + + if (isProtoType(field.resolvedType)) { + return mockTypeFields(field.resolvedType, stackDepth); + } + + const mockPropertyValue = mockScalar(field.type, field.name); + + if (mockPropertyValue === null) { + const resolvedField = field.resolve(); + + return mockField(resolvedField, stackDepth); + } else { + return mockPropertyValue; + } +} + +function mockMapField(field: MapField, stackDepth: StackDepth): any { + let mockPropertyValue = null; + if (field.resolvedType === null) { + mockPropertyValue = mockScalar(field.type, field.name); + } + + if (mockPropertyValue === null) { + const resolvedType = field.resolvedType; + + if (resolvedType instanceof Type) { + if (resolvedType.oneofs) { + mockPropertyValue = pickOneOf(resolvedType.oneofsArray, stackDepth); + } else { + mockPropertyValue = mockTypeFields(resolvedType, stackDepth); + } + } else if (resolvedType instanceof Enum) { + mockPropertyValue = mockEnum(resolvedType); + } else if (resolvedType === null) { + mockPropertyValue = {}; + } + + } + + return { + [mockScalar(field.keyType, field.name)]: mockPropertyValue, + }; +} + +function isProtoType(resolvedType: Enum | Type | null): resolvedType is Type { + if (!resolvedType) { + return false; + } + const fieldsArray: keyof Type = 'fieldsArray'; + return resolvedType instanceof Type || ( + fieldsArray in resolvedType && Array.isArray(resolvedType[fieldsArray]) + ); +} + +function pickOneOf(oneofs: OneOf[], stackDepth: StackDepth) { + return oneofs.reduce((fields: {[key: string]: any}, oneOf) => { + fields[oneOf.name] = mockField(oneOf.fieldsArray[0], stackDepth); + return fields; + }, {}); +} + +function mockScalar(type: string, fieldName: string): any { + switch (type) { + case 'string': + return interpretMockViaFieldName(fieldName); + case 'number': + return 10; + case 'bool': + return true; + case 'int32': + return 10; + case 'int64': + return 20; + case 'uint32': + return 100; + case 'uint64': + return 100; + case 'sint32': + return 100; + case 'sint64': + return 1200; + case 'fixed32': + return 1400; + case 'fixed64': + return 1500; + case 'sfixed32': + return 1600; + case 'sfixed64': + return 1700; + case 'double': + return 1.4; + case 'float': + return 1.1; + case 'bytes': + return Buffer.from([0xa1, 0xb2, 0xc3]); + default: + return null; + } +} + +/** + * Tries to guess a mock value from the field name. + * Default Hello. + */ +function interpretMockViaFieldName(fieldName: string): string { + const fieldNameLower = fieldName.toLowerCase(); + + if (fieldNameLower.startsWith('id') || fieldNameLower.endsWith('id')) { + return v4(); + } + + return 'Hello'; +} + +class StackDepth { + private readonly depths: { [type: string]: number }; + readonly maxStackSize: number; + + constructor(maxStackSize = 3) { + this.depths = {}; + this.maxStackSize = maxStackSize; + } + + incrementAndCheckIfOverMax(key: string): boolean { + if (this.depths[key] > this.maxStackSize) { + return true; + } + if (!this.depths[key]) { + this.depths[key] = 0; + } + this.depths[key]++; + return false; + } +} diff --git a/packages/insomnia/src/main/ipc/grpc.ts b/packages/insomnia/src/main/ipc/grpc.ts index 2790eb3ad..5496c787e 100644 --- a/packages/insomnia/src/main/ipc/grpc.ts +++ b/packages/insomnia/src/main/ipc/grpc.ts @@ -1,9 +1,7 @@ -import { Call, ClientDuplexStream, ClientReadableStream, ServiceError, StatusObject } from '@grpc/grpc-js'; -import { credentials, makeGenericClientConstructor, Metadata, status } from '@grpc/grpc-js'; +import { Call, ClientDuplexStream, ClientReadableStream, credentials, makeGenericClientConstructor, Metadata, ServiceError, status, StatusObject } from '@grpc/grpc-js'; import * as protoLoader from '@grpc/proto-loader'; import { AnyDefinition, EnumTypeDefinition, MessageTypeDefinition, PackageDefinition, ServiceDefinition } from '@grpc/proto-loader'; -import electron, { ipcMain } from 'electron'; -import { IpcMainEvent } from 'electron'; +import electron, { ipcMain, IpcMainEvent } from 'electron'; import * as grpcReflection from 'grpc-reflection-js'; import type { RenderedGrpcRequest, RenderedGrpcRequestBody } from '../../common/render'; @@ -12,6 +10,7 @@ import type { GrpcRequest, GrpcRequestHeader } from '../../models/grpc-request'; import { parseGrpcUrl } from '../../network/grpc/parse-grpc-url'; import { writeProtoFile } from '../../network/grpc/write-proto-file'; import { invariant } from '../../utils/invariant'; +import { mockRequestMethods } from './automock'; const grpcCalls = new Map(); export interface GrpcIpcRequestParams { @@ -74,6 +73,7 @@ interface MethodDefs { responseStream: boolean; requestSerialize: (value: any) => Buffer; responseDeserialize: (value: Buffer) => any; + example?: Record; } const getMethodsFromReflection = async (host: string, metadata: GrpcRequestHeader[]): Promise => { try { @@ -83,18 +83,29 @@ const getMethodsFromReflection = async (host: string, metadata: GrpcRequestHeade grpcOptions, filterDisabledMetaData(metadata) ); - const services = await client.listServices() as string[]; + const services = await client.listServices(); const methodsPromises = services.map(async service => { const fileContainingSymbol = await client.fileContainingSymbol(service); + const fullService = fileContainingSymbol.lookupService(service); + const mockedRequestMethods = mockRequestMethods(fullService); const descriptorMessage = fileContainingSymbol.toDescriptor('proto3'); + const packageDefinition = protoLoader.loadFileDescriptorSetFromObject(descriptorMessage, {}); const tryToGetMethods = () => { try { console.log('[grpc] loading service from reflection:', service); - const packageDefinition = protoLoader.loadFileDescriptorSetFromObject(descriptorMessage, {}); const serviceDefinition = asServiceDefinition(packageDefinition[service]); invariant(serviceDefinition, `'${service}' was not a valid ServiceDefinition`); const serviceMethods = Object.values(serviceDefinition); - return serviceMethods; + return serviceMethods.map(m => { + const methodName = Object.keys(mockedRequestMethods).find(name => m.path.endsWith(`/${name}`)); + if (!methodName) { + return m; + } + return { + ...m, + example: mockedRequestMethods[methodName]().plain, + }; + }); } catch (e) { console.error(e); return []; @@ -114,11 +125,13 @@ export const loadMethodsFromReflection = async (options: { url: string; metadata return methods.map(method => ({ type: getMethodType(method), fullPath: method.path, + example: method.example, })); }; export interface GrpcMethodInfo { type: GrpcMethodType; fullPath: string; + example?: Record; } export const getMethodType = ({ requestStream, responseStream }: any): GrpcMethodType => { if (requestStream && responseStream) { diff --git a/packages/insomnia/src/ui/components/editors/grpc-editor.tsx b/packages/insomnia/src/ui/components/editors/grpc-editor.tsx deleted file mode 100644 index 82c6ca9fa..000000000 --- a/packages/insomnia/src/ui/components/editors/grpc-editor.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { FunctionComponent } from 'react'; -import { useSelector } from 'react-redux'; - -import * as models from '../../../models'; -import type { Response } from '../../../models/response'; -import { useRequestMetaPatcher } from '../../hooks/use-request'; -import { selectActiveResponse } from '../../redux/selectors'; -import { CodeEditor } from '../codemirror/code-editor'; - -interface Props { - content?: string; - handleChange?: (arg0: string) => void; - readOnly?: boolean; -} - -export const GRPCEditor: FunctionComponent = ({ - content, - handleChange, - readOnly, -}) => { - const response = useSelector(selectActiveResponse) as Response | null; - const patchRequestMeta = useRequestMetaPatcher(); - const handleSetFilter = async (responseFilter: string) => { - if (!response) { - return; - } - const requestId = response.parentId; - await patchRequestMeta(requestId, { responseFilter }); - const meta = await models.requestMeta.getByParentId(requestId); - if (!meta) { - return; - } - const responseFilterHistory = meta.responseFilterHistory.slice(0, 10); - // Already in history or empty? - if (!responseFilter || responseFilterHistory.includes(responseFilter)) { - return; - } - responseFilterHistory.unshift(responseFilter); - patchRequestMeta(requestId, { responseFilterHistory }); - }; - return (); -}; diff --git a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx index 458f28cb6..8c2b6f394 100644 --- a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useRef, useState } from 'react'; import { useParams, useRouteLoaderData } from 'react-router-dom'; import { useMount } from 'react-use'; import styled from 'styled-components'; @@ -18,6 +18,7 @@ import { RequestLoaderData } from '../../routes/request'; import { WorkspaceLoaderData } from '../../routes/workspace'; import { PanelContainer, TabItem, Tabs } from '../base/tabs'; import { GrpcSendButton } from '../buttons/grpc-send-button'; +import { CodeEditor, CodeEditorHandle } from '../codemirror/code-editor'; import { OneLineEditor } from '../codemirror/one-line-editor'; import { GrpcMethodDropdown } from '../dropdowns/grpc-method-dropdown/grpc-method-dropdown'; import { ErrorBoundary } from '../error-boundary'; @@ -30,7 +31,6 @@ import { RequestRenderErrorModal } from '../modals/request-render-error-modal'; import { SvgIcon } from '../svg-icon'; import { Button } from '../themed-button'; import { Tooltip } from '../tooltip'; -import { GrpcTabbedMessages } from '../viewers/grpc-tabbed-messages'; import { EmptyStatePane } from './empty-state-pane'; import { Pane, PaneBody, PaneHeader } from './pane'; interface Props { @@ -85,7 +85,7 @@ export const GrpcRequestPane: FunctionComponent = ({ const methods = await window.main.grpc.loadMethods(activeRequest.protoFileId); setGrpcState({ ...grpcState, methods }); }); - + const editorRef = useRef(null); const gitVersion = useGitVCSVersion(); const activeRequestSyncVersion = useActiveRequestSyncVCSVersion(); const { workspaceId, requestId } = useParams() as { workspaceId: string; requestId: string }; @@ -175,6 +175,20 @@ export const GrpcRequestPane: FunctionComponent = ({ }); }} /> + + + + )} + + {[ + + patchRequest(requestId, { body: { text } })} + mode="application/json" + enableNunjucks + showPrettifyButton={true} + /> + , + ...requestMessages.sort((a, b) => a.created - b.created).map((m, index) => ( + + + + )), + ]} + + @@ -292,3 +338,12 @@ export const GrpcRequestPane: FunctionComponent = ({ ); }; +const ActionButtonsContainer = styled.div({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + boxSizing: 'border-box', + height: 'var(--line-height-sm)', + borderBottom: '1px solid var(--hl-lg)', + padding: 3, +}); diff --git a/packages/insomnia/src/ui/components/panes/grpc-response-pane.tsx b/packages/insomnia/src/ui/components/panes/grpc-response-pane.tsx index 069d5608b..c2e3859e6 100644 --- a/packages/insomnia/src/ui/components/panes/grpc-response-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/grpc-response-pane.tsx @@ -1,46 +1,40 @@ import React, { FunctionComponent } from 'react'; -import { useRouteLoaderData } from 'react-router-dom'; -import { useParams } from 'react-router-dom'; -import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version'; import { GrpcRequestState } from '../../routes/debug'; -import { WorkspaceLoaderData } from '../../routes/workspace'; +import { TabItem, Tabs } from '../base/tabs'; +import { CodeEditor } from '../codemirror/code-editor'; import { GrpcStatusTag } from '../tags/grpc-status-tag'; -import { GrpcTabbedMessages } from '../viewers/grpc-tabbed-messages'; import { Pane, PaneBody, PaneHeader } from './pane'; interface Props { grpcState: GrpcRequestState; } -export const GrpcResponsePane: FunctionComponent = ({ grpcState }) => { - const gitVersion = useGitVCSVersion(); - const activeRequestSyncVersion = useActiveRequestSyncVCSVersion(); - const { - activeEnvironment, - } = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData; - const { requestId } = useParams() as { requestId: string }; - - // Force re-render when we switch requests, the environment gets modified, or the (Git|Sync)VCS version changes - const uniquenessKey = `${activeEnvironment.modified}::${requestId}::${gitVersion}::${activeRequestSyncVersion}`; - - const { responseMessages, status, error } = grpcState; +export const GrpcResponsePane: FunctionComponent = ({ grpcState: { running, responseMessages, status, error } }) => { return (
- {grpcState.running && } + {running && } {status && } {!status && error && }
- - {!!responseMessages.length && ( - - )} + + {responseMessages.length + ? ( + {responseMessages.sort((a, b) => a.created - b.created).map((m, index) => ( + + + ))} + ) + : null + }
); diff --git a/packages/insomnia/src/ui/components/viewers/grpc-tabbed-messages.tsx b/packages/insomnia/src/ui/components/viewers/grpc-tabbed-messages.tsx deleted file mode 100644 index d7dad9966..000000000 --- a/packages/insomnia/src/ui/components/viewers/grpc-tabbed-messages.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { FunctionComponent } from 'react'; -import styled from 'styled-components'; - -import { HandleGetRenderContext, HandleRender } from '../../../common/render'; -import { TabItem, Tabs } from '../base/tabs'; -import { GRPCEditor } from '../editors/grpc-editor'; - -interface Message { - id: string; - created: number; - text: string; -} - -interface Props { - messages?: Message[]; - tabNamePrefix: 'Stream' | 'Response'; - bodyText?: string; - uniquenessKey: string; - handleBodyChange?: (arg0: string) => void; - handleStream?: () => void; - handleCommit?: () => void; - showActions?: boolean; - handleRender?: HandleRender; - handleGetRenderContext?: HandleGetRenderContext; -} - -const ActionButtonsContainer = styled.div({ - display: 'flex', - flexDirection: 'row', - justifyContent: 'flex-end', - boxSizing: 'border-box', - height: 'var(--line-height-sm)', - borderBottom: '1px solid var(--hl-lg)', - padding: 3, -}); - -export const GrpcTabbedMessages: FunctionComponent = ({ - showActions, - bodyText, - messages, - tabNamePrefix, - handleBodyChange, - handleCommit, - handleStream, - uniquenessKey, -}) => { - const shouldShowBody = !!handleBodyChange; - const orderedMessages = messages?.sort((a, b) => a.created - b.created) || []; - - const tabItems = []; - - if (shouldShowBody) { - tabItems.push( - - - - ); - } - - orderedMessages.map((m, index) => tabItems.push( - - - - )); - - return ( - <> - {showActions && ( - - {handleStream && ( - - )} - {handleCommit && ( - - )} - - )} - - {tabItems} - - - ); -};