From 3b14e997dd91dcd5fe80a5a4fea382aefbf49dee Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 23 Dec 2025 20:43:21 +0900 Subject: [PATCH] feat(@probitas/client-connectrpc,@probitas/client-grpc): separate JSON and protobuf representations in responses Response objects now provide two distinct data access patterns: - response.data: Plain JSON with camelCase fields (via toJson) - response.raw: Original protobuf Message with $typeName metadata This separation addresses the mismatch between protobuf's snake_case convention and JavaScript's camelCase convention. Users working with JSON APIs get clean, serializable objects without protobuf metadata, while users needing protobuf-specific features retain full access to the original Message. The implementation retrieves message schemas from gRPC reflection (FileRegistry) and converts responses using @bufbuild/protobuf's toJson(). Error handling ensures graceful fallback to raw messages if conversion fails. Changes: - Add schema parameter to ConnectRpcResponseSuccessParams - Implement toJson conversion in ConnectRpcResponseSuccessImpl - Retrieve output schemas in all 4 RPC methods (call, serverStream, clientStream, bidiStream) - Match methods by localName (camelCase) instead of name (PascalCase) - Add comprehensive field name convention documentation - Add tests for schema conversion and error handling --- packages/probitas-client-connectrpc/client.ts | 58 +++++++++++++ .../probitas-client-connectrpc/response.ts | 81 +++++++++++++++++-- .../response_test.ts | 71 ++++++++++++++++ packages/probitas-client-grpc/mod.ts | 33 ++++++++ 4 files changed, 236 insertions(+), 7 deletions(-) diff --git a/packages/probitas-client-connectrpc/client.ts b/packages/probitas-client-connectrpc/client.ts index 32dd14a..c68a15e 100644 --- a/packages/probitas-client-connectrpc/client.ts +++ b/packages/probitas-client-connectrpc/client.ts @@ -106,6 +106,32 @@ export interface ReflectionApi { /** * ConnectRPC client interface. + * + * ## Field Name Conventions + * + * This client automatically handles field name conversion between protobuf and JavaScript: + * + * - **Request fields**: Accept both `snake_case` (protobuf style) and `camelCase` (JavaScript style) + * - **Response fields**: Converted to JavaScript conventions based on response type: + * - `response.data`: Plain JSON object with `camelCase` field names (no `$typeName`) + * - `response.raw`: Original protobuf Message object with all metadata (includes `$typeName`) + * + * @example + * ```ts + * const client = createConnectRpcClient({ url: "http://localhost:50051" }); + * + * // Request: Both formats work + * await client.call("echo.Echo", "echoWithDelay", { + * message: "hello", + * delayMs: 100, // camelCase (recommended) + * // delay_ms: 100, // snake_case also works + * }); + * + * // Response: data is JSON, raw is protobuf Message + * const response = await client.call("echo.Echo", "echo", { message: "test" }); + * console.log(response.data); // { message: "test", metadata: {...} } + * console.log(response.raw); // { $typeName: "echo.EchoResponse", message: "test", ... } + * ``` */ export interface ConnectRpcClient extends AsyncDisposable { /** Client configuration */ @@ -732,8 +758,16 @@ class ConnectRpcClientImpl implements ConnectRpcClient { ); const duration = performance.now() - startTime; + // Get output message schema for toJson conversion + const registry = await this.#getFileRegistry(); + const service = registry.getService(serviceName); + // Match by localName (camelCase) since that's what users provide + const method = service?.methods.find((m) => m.localName === methodName); + const outputSchema = method?.output; + return new ConnectRpcResponseSuccessImpl({ response, + schema: outputSchema ?? null, headers, trailers, duration, @@ -821,6 +855,13 @@ class ConnectRpcClientImpl implements ConnectRpcClient { }, }; + // Get output message schema for toJson conversion + const registry = await this.#getFileRegistry(); + const service = registry.getService(serviceName); + // Match by localName (camelCase) since that's what users provide + const method = service?.methods.find((m) => m.localName === methodName); + const outputSchema = method?.output; + const stream = dynamicClient.serverStream( serviceName, methodName, @@ -833,6 +874,7 @@ class ConnectRpcClientImpl implements ConnectRpcClient { const duration = performance.now() - startTime; yield new ConnectRpcResponseSuccessImpl({ response: message, + schema: outputSchema ?? null, headers, trailers, duration, @@ -929,8 +971,16 @@ class ConnectRpcClientImpl implements ConnectRpcClient { ); const duration = performance.now() - startTime; + // Get output message schema for toJson conversion + const registry = await this.#getFileRegistry(); + const service = registry.getService(serviceName); + // Match by localName (camelCase) since that's what users provide + const method = service?.methods.find((m) => m.localName === methodName); + const outputSchema = method?.output; + return new ConnectRpcResponseSuccessImpl({ response, + schema: outputSchema ?? null, headers, trailers, duration, @@ -1017,6 +1067,13 @@ class ConnectRpcClientImpl implements ConnectRpcClient { }, }; + // Get output message schema for toJson conversion + const registry = await this.#getFileRegistry(); + const service = registry.getService(serviceName); + // Match by localName (camelCase) since that's what users provide + const method = service?.methods.find((m) => m.localName === methodName); + const outputSchema = method?.output; + const stream = dynamicClient.bidiStream( serviceName, methodName, @@ -1029,6 +1086,7 @@ class ConnectRpcClientImpl implements ConnectRpcClient { const duration = performance.now() - startTime; yield new ConnectRpcResponseSuccessImpl({ response: message, + schema: outputSchema ?? null, headers, trailers, duration, diff --git a/packages/probitas-client-connectrpc/response.ts b/packages/probitas-client-connectrpc/response.ts index 04d100c..a04daa4 100644 --- a/packages/probitas-client-connectrpc/response.ts +++ b/packages/probitas-client-connectrpc/response.ts @@ -5,6 +5,9 @@ */ import type { ConnectError } from "@connectrpc/connect"; +import type { DescMessage } from "@bufbuild/protobuf"; +import { toJson } from "@bufbuild/protobuf"; +import { getLogger } from "@logtape/logtape"; import type { ClientResult } from "@probitas/client"; import type { ConnectRpcError, @@ -13,6 +16,8 @@ import type { } from "./errors.ts"; import type { ConnectRpcStatusCode } from "./status.ts"; +const logger = getLogger(["probitas", "client", "connectrpc", "response"]); + /** * ConnectRPC error type union. * @@ -24,6 +29,45 @@ export type ConnectRpcErrorType = ConnectRpcError | ConnectRpcNetworkError; /** * Base interface for all ConnectRPC response types. + * + * ## Field Name Conversion + * + * Response messages are automatically converted from protobuf format to JavaScript format: + * + * - **data field**: Plain JSON object with `camelCase` field names (e.g., `delayMs`) + * - No `$typeName` metadata + * - Ready for JSON serialization + * - Suitable for logging, storage, and API responses + * + * - **raw field**: Original protobuf Message object + * - Includes `$typeName` metadata + * - Access to protobuf-specific features + * - Use for field presence checking or binary operations + * + * @example + * ```ts + * import { createConnectRpcClient } from "@probitas/client-connectrpc"; + * + * const client = createConnectRpcClient({ url: "http://localhost:50051" }); + * + * const response = await client.call("echo.Echo", "echoWithDelay", { + * message: "hello", + * delayMs: 100 + * }); + * + * // data: Plain JSON (camelCase, no $typeName) + * console.log(response.data); + * // { message: "hello", metadata: {...} } + * + * // raw: Protobuf Message (with $typeName) + * console.log(response.raw); + * // { $typeName: "echo.EchoResponse", message: "hello", metadata: {...} } + * + * // Serialize to JSON + * const json = JSON.stringify(response.data); // Works cleanly + * + * await client.close(); + * ``` */ // deno-lint-ignore no-explicit-any interface ConnectRpcResponseBase extends ClientResult { @@ -66,14 +110,17 @@ interface ConnectRpcResponseBase extends ClientResult { readonly duration: number; /** - * Deserialized response data. - * The response message as-is (already deserialized by Connect). + * Response data as plain JavaScript object (converted using toJson). + * This is the JSON representation with camelCase field names, + * suitable for serialization and general use. * Null if the response is an error or has no data. */ readonly data: T | null; /** - * Raw response or error. + * Raw protobuf Message object with all protobuf metadata. + * Use this when you need access to protobuf-specific features + * like field presence checking or working with binary data. * Null for failure responses. */ readonly raw: unknown | null; @@ -101,10 +148,10 @@ export interface ConnectRpcResponseSuccess /** Response trailers (sent at end of RPC). */ readonly trailers: Headers; - /** Raw response. */ + /** Raw protobuf Message object. */ readonly raw: unknown; - /** Response data. */ + /** Response data as plain JavaScript object (JSON representation). */ readonly data: T | null; } @@ -193,6 +240,7 @@ export type ConnectRpcResponse = // deno-lint-ignore no-explicit-any export interface ConnectRpcResponseSuccessParams { readonly response: T | null; + readonly schema: DescMessage | null; readonly headers: Headers; readonly trailers: Headers; readonly duration: number; @@ -233,14 +281,33 @@ export class ConnectRpcResponseSuccessImpl readonly trailers: Headers; readonly duration: number; readonly data: T | null; - readonly raw: T | null; + readonly raw: unknown | null; constructor(params: ConnectRpcResponseSuccessParams) { this.headers = params.headers; this.trailers = params.trailers; this.duration = params.duration; - this.data = params.response; this.raw = params.response; + + // Convert protobuf message to JSON if schema is available + if (params.response && params.schema) { + try { + // deno-lint-ignore no-explicit-any + this.data = toJson(params.schema, params.response as any) as T; + } catch (error) { + // If toJson fails, fall back to raw response + logger.debug( + "Failed to convert protobuf message to JSON, using raw message", + { + schema: params.schema.typeName, + error: error instanceof Error ? error.message : String(error), + }, + ); + this.data = params.response; + } + } else { + this.data = params.response; + } } } diff --git a/packages/probitas-client-connectrpc/response_test.ts b/packages/probitas-client-connectrpc/response_test.ts index 41badb1..c3b8a24 100644 --- a/packages/probitas-client-connectrpc/response_test.ts +++ b/packages/probitas-client-connectrpc/response_test.ts @@ -5,6 +5,7 @@ import { assert, assertEquals, + assertExists, assertFalse, assertInstanceOf, } from "@std/assert"; @@ -20,6 +21,7 @@ Deno.test("ConnectRpcResponseSuccessImpl", async (t) => { await t.step("creates success response with data", () => { const response = new ConnectRpcResponseSuccessImpl({ response: { user: { id: 1, name: "John" } }, + schema: null, headers: new Headers(), trailers: new Headers(), duration: 100, @@ -40,6 +42,7 @@ Deno.test("ConnectRpcResponseSuccessImpl", async (t) => { const trailers = new Headers({ "grpc-status": "0" }); const response = new ConnectRpcResponseSuccessImpl({ response: { test: true }, + schema: null, headers, trailers, duration: 50, @@ -55,6 +58,7 @@ Deno.test("ConnectRpcResponseSuccessImpl", async (t) => { const rawResponse = { nested: { value: 123 } }; const response = new ConnectRpcResponseSuccessImpl({ response: rawResponse, + schema: null, headers: new Headers(), trailers: new Headers(), duration: 10, @@ -70,6 +74,7 @@ Deno.test("ConnectRpcResponseSuccessImpl", async (t) => { } const response = new ConnectRpcResponseSuccessImpl({ response: { user: { id: 1, name: "John" } }, + schema: null, headers: new Headers(), trailers: new Headers(), duration: 100, @@ -83,6 +88,7 @@ Deno.test("ConnectRpcResponseSuccessImpl", async (t) => { await t.step("handles null response", () => { const response = new ConnectRpcResponseSuccessImpl({ response: null, + schema: null, headers: new Headers(), trailers: new Headers(), duration: 100, @@ -91,6 +97,71 @@ Deno.test("ConnectRpcResponseSuccessImpl", async (t) => { assertEquals(response.data, null); assertEquals(response.raw, null); }); + + await t.step("with schema: data and raw are different objects", () => { + // When schema is provided (even if invalid for toJson), + // the code attempts conversion and may fall back + // deno-lint-ignore no-explicit-any + const mockSchema: any = { + typeName: "test.Message", + fields: [], + file: {}, + kind: "message", + name: "Message", + }; + + const mockMessage = { + $typeName: "test.Message", + message: "Hello", + count: 42, + }; + + const response = new ConnectRpcResponseSuccessImpl({ + response: mockMessage, + schema: mockSchema, + headers: new Headers(), + trailers: new Headers(), + duration: 100, + }); + + // With our mock schema, toJson might fail and fall back to raw message + // The important thing is that the code handles it gracefully + assertExists(response.data); + assertExists(response.raw); + assertEquals(response.raw, mockMessage); + + // This test verifies that the error handling doesn't crash + // In real usage with proper schemas from FileRegistry, toJson will work correctly + }); + + await t.step("falls back to raw message when toJson fails", () => { + // Create an invalid schema that will cause toJson to fail + // deno-lint-ignore no-explicit-any + const invalidSchema: any = { + typeName: "invalid.Schema", + fields: undefined, // Invalid structure + file: undefined, + kind: "message", + name: "Invalid", + }; + + const mockMessage = { + $typeName: "test.Message", + data: "test", + }; + + const response = new ConnectRpcResponseSuccessImpl({ + response: mockMessage, + schema: invalidSchema, + headers: new Headers(), + trailers: new Headers(), + duration: 100, + }); + + // When toJson fails, data should fall back to raw message + assertEquals(response.data, mockMessage); + assertEquals(response.raw, mockMessage); + }); }); Deno.test("ConnectRpcResponseErrorImpl", async (t) => { diff --git a/packages/probitas-client-grpc/mod.ts b/packages/probitas-client-grpc/mod.ts index 164c328..428defb 100644 --- a/packages/probitas-client-grpc/mod.ts +++ b/packages/probitas-client-grpc/mod.ts @@ -10,6 +10,7 @@ * * - **Native gRPC**: Uses gRPC protocol (HTTP/2 with binary protobuf) * - **Server Reflection**: Auto-discover services and methods at runtime + * - **Field Name Conversion**: Automatic snake_case ↔ camelCase conversion * - **TLS Support**: Configure secure connections with custom certificates * - **Duration Tracking**: Built-in timing for performance monitoring * - **Error Handling**: Test error responses without throwing exceptions @@ -60,6 +61,38 @@ * await client.close(); * ``` * + * ## Field Name Conventions + * + * The client automatically handles field name conversion between protobuf and JavaScript: + * + * - **Request**: Accept both `snake_case` (protobuf) and `camelCase` (JavaScript) field names + * - **Response**: + * - `response.data` — Plain JSON with `camelCase` fields (no `$typeName`) + * - `response.raw` — Original protobuf Message with metadata (includes `$typeName`) + * + * ```ts + * import { createGrpcClient } from "@probitas/client-grpc"; + * + * const client = createGrpcClient({ url: "http://localhost:50051" }); + * + * // Request: Both camelCase and snake_case work + * const response = await client.call("echo.Echo", "echoWithDelay", { + * message: "hello", + * delayMs: 100, // camelCase (recommended for JavaScript) + * // delay_ms: 100, // snake_case (protobuf style) also works + * }); + * + * // Response data: JSON with camelCase fields + * console.log(response.data); + * // { message: "hello", metadata: {...} } ← no $typeName + * + * // Response raw: protobuf Message with metadata + * console.log(response.raw); + * // { $typeName: "echo.EchoResponse", message: "hello", ... } + * + * await client.close(); + * ``` + * * ## Using with `using` Statement * * ```ts