Skip to content

Commit 8a6de3d

Browse files
Add support for duration strings, any type, field mask and fix a bug around wrapper known type (#5)
* Add support for duration strings, fix a bug around wrapper known type * Add oneof test for round trip * Add support for any and fieldmask * Add field mask with array ui * Add drop down support for Any fields * Add support for any type * Add messageType labels * Refactor * Restore test
1 parent a2bb4fd commit 8a6de3d

42 files changed

Lines changed: 3276 additions & 168 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/grpc/connect/connectInvoker.ts

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import {
1111
type DescMethodUnary,
1212
type JsonValue as BufJsonValue,
1313
type MessageInitShape,
14+
type Registry,
1415
} from '@bufbuild/protobuf'
16+
import type { FileDescriptorSet } from '@bufbuild/protobuf/wkt'
1517
import { Code, ConnectError, createClient, type CallOptions } from '@connectrpc/connect'
18+
import { buildDynamicRegistry } from './dynamicRegistry.js'
1619
import type { JsonValue } from '@grpc-studio/shared'
1720
import configManager from '../../config/configManager.js'
1821
import type { FormattedGrpcError, StreamCallbacks, StreamHandle } from '../../types/index.js'
@@ -46,40 +49,51 @@ export type OutboundHeaders = Record<string, string>
4649
export class ConnectInvoker {
4750
constructor(private readonly transportProvider: ConnectTransportProvider = connectTransportProvider) {}
4851

49-
async invokeUnary(method: DescMethodUnary, request: JsonValue | undefined, headers: OutboundHeaders): Promise<JsonValue> {
52+
async invokeUnary(
53+
method: DescMethodUnary,
54+
request: JsonValue | undefined,
55+
headers: OutboundHeaders,
56+
fileDescriptorSet: FileDescriptorSet
57+
): Promise<JsonValue> {
58+
const registry = buildDynamicRegistry(fileDescriptorSet)
5059
const response = await this.getClientMethod<UnaryClientMethod>(method)(
51-
toProtoRequest(method.input, request),
60+
toProtoRequest(method.input, request, registry),
5261
this.buildCallOptions(configManager.getClientConfig().rpc.unaryDeadlineMs, headers)
5362
)
5463

55-
return toJsonResponse(method.output, response)
64+
return toJsonResponse(method.output, response, registry)
5665
}
5766

5867
async startServerStream(
5968
method: DescMethodServerStreaming,
6069
request: JsonValue | undefined,
6170
callbacks: StreamCallbacks,
62-
headers: OutboundHeaders
71+
headers: OutboundHeaders,
72+
fileDescriptorSet: FileDescriptorSet
6373
): Promise<StreamHandle> {
74+
const registry = buildDynamicRegistry(fileDescriptorSet)
6475
return this.startResponseStream(
6576
callbacks,
6677
async (signal) => this.getClientMethod<ServerStreamingClientMethod>(method)(
67-
toProtoRequest(method.input, request),
78+
toProtoRequest(method.input, request, registry),
6879
this.buildCallOptions(configManager.getClientConfig().rpc.streamDeadlineMs, headers, signal)
6980
),
70-
method.output
81+
method.output,
82+
registry
7183
)
7284
}
7385

7486
async startClientStream(
7587
method: DescMethodClientStreaming,
7688
requests: RequestStream,
7789
callbacks: StreamCallbacks,
78-
headers: OutboundHeaders
90+
headers: OutboundHeaders,
91+
fileDescriptorSet: FileDescriptorSet
7992
): Promise<StreamHandle> {
8093
const abortController = new AbortController()
94+
const registry = buildDynamicRegistry(fileDescriptorSet)
8195

82-
this.pumpClientStream(method, requests, callbacks, headers, abortController.signal)
96+
this.pumpClientStream(method, requests, callbacks, headers, abortController.signal, registry)
8397
.catch((error) => {
8498
// Catch unhandled errors from pump to prevent unhandled promise rejections
8599
if (!abortController.signal.aborted) {
@@ -98,26 +112,30 @@ export class ConnectInvoker {
98112
method: DescMethodBiDiStreaming,
99113
requests: RequestStream,
100114
callbacks: StreamCallbacks,
101-
headers: OutboundHeaders
115+
headers: OutboundHeaders,
116+
fileDescriptorSet: FileDescriptorSet
102117
): Promise<StreamHandle> {
118+
const registry = buildDynamicRegistry(fileDescriptorSet)
103119
return this.startResponseStream(
104120
callbacks,
105121
async (signal) => this.getClientMethod<BidiStreamingClientMethod>(method)(
106-
toConnectRequestStream(method, requests),
122+
toConnectRequestStream(method, requests, registry),
107123
this.buildCallOptions(configManager.getClientConfig().rpc.streamDeadlineMs, headers, signal)
108124
),
109-
method.output
125+
method.output,
126+
registry
110127
)
111128
}
112129

113130
private startResponseStream(
114131
callbacks: StreamCallbacks,
115132
createStream: (signal: AbortSignal) => Promise<AsyncIterable<unknown>>,
116-
responseDesc: DescMessage
133+
responseDesc: DescMessage,
134+
registry: Registry
117135
): StreamHandle {
118136
const abortController = new AbortController()
119137

120-
this.pumpResponseStream(createStream, responseDesc, callbacks, abortController.signal)
138+
this.pumpResponseStream(createStream, responseDesc, callbacks, abortController.signal, registry)
121139
.catch((error) => {
122140
// Catch unhandled errors from pump to prevent unhandled promise rejections
123141
if (!abortController.signal.aborted) {
@@ -136,13 +154,14 @@ export class ConnectInvoker {
136154
createStream: (signal: AbortSignal) => Promise<AsyncIterable<unknown>>,
137155
responseDesc: DescMessage,
138156
callbacks: StreamCallbacks,
139-
signal: AbortSignal
157+
signal: AbortSignal,
158+
registry: Registry
140159
): Promise<void> {
141160
try {
142161
const stream = await createStream(signal)
143162

144163
for await (const message of stream) {
145-
callbacks.onData(toJsonResponse(responseDesc, message))
164+
callbacks.onData(toJsonResponse(responseDesc, message, registry))
146165
}
147166

148167
callbacks.onEnd()
@@ -158,15 +177,16 @@ export class ConnectInvoker {
158177
requests: RequestStream,
159178
callbacks: StreamCallbacks,
160179
headers: OutboundHeaders,
161-
signal: AbortSignal
180+
signal: AbortSignal,
181+
registry: Registry
162182
): Promise<void> {
163183
try {
164184
const response = await this.getClientMethod<ClientStreamingClientMethod>(method)(
165-
toConnectRequestStream(method, requests),
185+
toConnectRequestStream(method, requests, registry),
166186
this.buildCallOptions(configManager.getClientConfig().rpc.streamDeadlineMs, headers, signal)
167187
)
168188

169-
callbacks.onData(toJsonResponse(method.output, response))
189+
callbacks.onData(toJsonResponse(method.output, response, registry))
170190
callbacks.onEnd()
171191
} catch (error) {
172192
if (!signal.aborted) {
@@ -206,19 +226,20 @@ export function formatConnectError(error: unknown): FormattedGrpcError {
206226
}
207227
}
208228

209-
function toProtoRequest(desc: DescMessage, data: JsonValue | undefined): MessageInitShape<DescMessage> {
210-
return fromJson(desc, (data ?? {}) as BufJsonValue, { ignoreUnknownFields: true }) as MessageInitShape<DescMessage>
229+
function toProtoRequest(desc: DescMessage, data: JsonValue | undefined, registry: Registry): MessageInitShape<DescMessage> {
230+
return fromJson(desc, (data ?? {}) as BufJsonValue, { ignoreUnknownFields: true, registry }) as MessageInitShape<DescMessage>
211231
}
212232

213-
function toJsonResponse(desc: DescMessage, message: unknown): JsonValue {
214-
return toJson(desc, message as never, { alwaysEmitImplicit: true, useProtoFieldName: true }) as JsonValue
233+
function toJsonResponse(desc: DescMessage, message: unknown, registry: Registry): JsonValue {
234+
return toJson(desc, message as never, { alwaysEmitImplicit: true, useProtoFieldName: true, registry }) as JsonValue
215235
}
216236

217237
async function* toConnectRequestStream(
218238
method: DescMethod,
219-
requests: RequestStream
239+
requests: RequestStream,
240+
registry: Registry
220241
): AsyncIterable<MessageInitShape<DescMessage>> {
221242
for await (const request of requests) {
222-
yield toProtoRequest(method.input, request)
243+
yield toProtoRequest(method.input, request, registry)
223244
}
224245
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2026 Electronic Arts Inc. All rights reserved.
2+
3+
/**
4+
* Dynamic registry builder that combines well-known types with reflected schemas.
5+
* This enables google.protobuf.Any fields to contain custom message types discovered
6+
* via reflection, not just WKTs.
7+
*/
8+
9+
import { createRegistry, createFileRegistry, type DescMessage, type Registry } from '@bufbuild/protobuf'
10+
import * as wkt from '@bufbuild/protobuf/wkt'
11+
import type { FileDescriptorSet } from '@bufbuild/protobuf/wkt'
12+
import logger from '../../utils/logger.js'
13+
14+
const registryLogger = logger.child({ module: 'dynamic-registry' })
15+
16+
// Static registry containing all well-known types
17+
const wktRegistry: Registry = createRegistry(
18+
...(Object.values(wkt).filter(
19+
(v) => typeof v === 'object' && v !== null && 'typeName' in v && 'fields' in v
20+
) as DescMessage[])
21+
)
22+
23+
/**
24+
* Builds a Registry containing both WKTs and all message types from the given FileDescriptorSet.
25+
* This registry can be passed to fromJson/toJson so google.protobuf.Any fields can contain
26+
* custom types discovered through reflection.
27+
*/
28+
export function buildDynamicRegistry(fileDescriptorSet: FileDescriptorSet): Registry {
29+
const messageDescriptors: DescMessage[] = []
30+
31+
// Parse the raw FileDescriptorSet into a FileRegistry to get DescFile/DescMessage objects
32+
const fileRegistry = createFileRegistry(fileDescriptorSet)
33+
34+
// Extract all message descriptors from the parsed FileRegistry
35+
for (const file of fileRegistry.files) {
36+
for (const message of file.messages) {
37+
messageDescriptors.push(message)
38+
}
39+
}
40+
41+
registryLogger.debug('Building dynamic registry', {
42+
wktCount: Array.from(wktRegistry).length,
43+
reflectedCount: messageDescriptors.length,
44+
})
45+
46+
// Create a combined registry with WKTs + reflected types
47+
return createRegistry(...Array.from(wktRegistry), ...messageDescriptors)
48+
}
49+
50+
/**
51+
* Returns the base WKT-only registry for cases where no FileRegistry is available.
52+
*/
53+
export function getWktRegistry(): Registry {
54+
return wktRegistry
55+
}

backend/src/repositories/reflectionSchemaRepository.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* through gRPC reflection.
66
*/
77

8-
import { createFileRegistry, type FileRegistry } from '@bufbuild/protobuf'
9-
import type { FileDescriptorSet } from '@bufbuild/protobuf/wkt'
8+
import { createFileRegistry, type FileRegistry, create } from '@bufbuild/protobuf'
9+
import { FileDescriptorSetSchema, type FileDescriptorSet } from '@bufbuild/protobuf/wkt'
1010
import * as reflectionClient from '../grpc/reflection/reflectionClient.js'
1111
import * as descriptorSetFromReflection from '../grpc/reflection/descriptorSetFromReflection.js'
1212
import * as reflectionMetadataCache from '../cache/reflectionMetadataCache.js'
@@ -123,6 +123,32 @@ export class ReflectionSchemaRepository {
123123
}
124124
}
125125

126+
async getAllFileDescriptorSet(): Promise<FileDescriptorSet> {
127+
// Build a FileDescriptorSet containing ALL files from ALL services.
128+
// This is used for building a comprehensive registry for Any field decoding,
129+
// since Any can contain messages from any reflected service.
130+
const services = await this.listServices()
131+
const allDescriptorSets: FileDescriptorSet[] = []
132+
133+
for (const serviceName of services) {
134+
try {
135+
const descriptorSet = await this.getFileDescriptorSet(serviceName)
136+
allDescriptorSets.push(descriptorSet)
137+
} catch (error) {
138+
repositoryLogger.warn('Failed to get descriptor set for service, skipping', {
139+
serviceName,
140+
error: error instanceof Error ? error.message : String(error),
141+
})
142+
}
143+
}
144+
145+
// Merge all files from all descriptor sets
146+
const allFiles = allDescriptorSets.flatMap(ds => ds.file)
147+
148+
// Create a proper FileDescriptorSet instance using @bufbuild/protobuf's create helper
149+
return create(FileDescriptorSetSchema, { file: allFiles })
150+
}
151+
126152
clearCache(): void {
127153
serviceNamesCache.clear()
128154
fileDescriptorSetCache.clear()

backend/src/services/grpcMethodInvokerService.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const VALID_NAME_RE = /^[a-zA-Z_][\w.]*$/
2222

2323
export class GrpcMethodInvokerService {
2424
constructor(
25-
private readonly schemaRepository: Pick<ReflectionSchemaRepository, 'getFileRegistry'> = reflectionSchemaRepository,
25+
private readonly schemaRepository: Pick<ReflectionSchemaRepository, 'getFileRegistry' | 'getAllFileDescriptorSet'> = reflectionSchemaRepository,
2626
private readonly transportProvider: ConnectTransportProvider = connectTransportProvider,
2727
private readonly connectInvoker: ConnectInvoker = new ConnectInvoker(transportProvider)
2828
) {}
@@ -34,12 +34,14 @@ export class GrpcMethodInvokerService {
3434
async invokeUnary(serviceName: string, methodName: string, request: JsonValue | undefined): Promise<UnaryResult> {
3535
return instrumentUnaryCall(serviceName, methodName, async () => {
3636
try {
37+
// Use global descriptor set so Any fields can contain types from any reflected service
38+
const fileDescriptorSet = await this.schemaRepository.getAllFileDescriptorSet()
3739
const method = await this.resolveMethod(serviceName, methodName)
3840
assertUnaryMethod(method)
3941
const headers = await headerManager.getOutboundHeaders(userContextMiddleware.getCurrentUserContext())
4042
return {
4143
success: true,
42-
data: await this.connectInvoker.invokeUnary(method, request, headers),
44+
data: await this.connectInvoker.invokeUnary(method, request, headers, fileDescriptorSet),
4345
completedAtMs: Date.now(),
4446
}
4547
} catch (error) {
@@ -58,10 +60,11 @@ export class GrpcMethodInvokerService {
5860
): Promise<StreamHandle> {
5961
return instrumentStreamCall(serviceName, methodName, 'server_streaming', callbacks, async (wrappedCallbacks) => {
6062
try {
63+
const fileDescriptorSet = await this.schemaRepository.getAllFileDescriptorSet()
6164
const method = await this.resolveMethod(serviceName, methodName)
6265
assertServerStreamingMethod(method)
6366
const headers = await headerManager.getOutboundHeaders(userContextMiddleware.getCurrentUserContext())
64-
return await this.connectInvoker.startServerStream(method, request, wrappedCallbacks, headers)
67+
return await this.connectInvoker.startServerStream(method, request, wrappedCallbacks, headers, fileDescriptorSet)
6568
} catch (error) {
6669
wrappedCallbacks.onError(formatConnectError(error))
6770
return noopStreamHandle()
@@ -77,10 +80,11 @@ export class GrpcMethodInvokerService {
7780
): Promise<StreamHandle> {
7881
return instrumentStreamCall(serviceName, methodName, 'client_streaming', callbacks, async (wrappedCallbacks) => {
7982
try {
83+
const fileDescriptorSet = await this.schemaRepository.getAllFileDescriptorSet()
8084
const method = await this.resolveMethod(serviceName, methodName)
8185
assertClientStreamingMethod(method)
8286
const headers = await headerManager.getOutboundHeaders(userContextMiddleware.getCurrentUserContext())
83-
return await this.connectInvoker.startClientStream(method, requests, wrappedCallbacks, headers)
87+
return await this.connectInvoker.startClientStream(method, requests, wrappedCallbacks, headers, fileDescriptorSet)
8488
} catch (error) {
8589
wrappedCallbacks.onError(formatConnectError(error))
8690
return noopStreamHandle()
@@ -96,10 +100,11 @@ export class GrpcMethodInvokerService {
96100
): Promise<StreamHandle> {
97101
return instrumentStreamCall(serviceName, methodName, 'bidi_streaming', callbacks, async (wrappedCallbacks) => {
98102
try {
103+
const fileDescriptorSet = await this.schemaRepository.getAllFileDescriptorSet()
99104
const method = await this.resolveMethod(serviceName, methodName)
100105
assertBidiStreamingMethod(method)
101106
const headers = await headerManager.getOutboundHeaders(userContextMiddleware.getCurrentUserContext())
102-
return await this.connectInvoker.startBidiStream(method, requests, wrappedCallbacks, headers)
107+
return await this.connectInvoker.startBidiStream(method, requests, wrappedCallbacks, headers, fileDescriptorSet)
103108
} catch (error) {
104109
wrappedCallbacks.onError(formatConnectError(error))
105110
return noopStreamHandle()

0 commit comments

Comments
 (0)