Skip to content
This repository was archived by the owner on Dec 27, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/probitas-client-connectrpc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -833,6 +874,7 @@ class ConnectRpcClientImpl implements ConnectRpcClient {
const duration = performance.now() - startTime;
yield new ConnectRpcResponseSuccessImpl({
response: message,
schema: outputSchema ?? null,
headers,
trailers,
duration,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -1029,6 +1086,7 @@ class ConnectRpcClientImpl implements ConnectRpcClient {
const duration = performance.now() - startTime;
yield new ConnectRpcResponseSuccessImpl({
response: message,
schema: outputSchema ?? null,
headers,
trailers,
duration,
Expand Down
81 changes: 74 additions & 7 deletions packages/probitas-client-connectrpc/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
*
Expand All @@ -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<T = any> extends ClientResult {
Expand Down Expand Up @@ -66,14 +110,17 @@ interface ConnectRpcResponseBase<T = any> 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;
Expand Down Expand Up @@ -101,10 +148,10 @@ export interface ConnectRpcResponseSuccess<T = any>
/** 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;
}

Expand Down Expand Up @@ -193,6 +240,7 @@ export type ConnectRpcResponse<T = any> =
// deno-lint-ignore no-explicit-any
export interface ConnectRpcResponseSuccessParams<T = any> {
readonly response: T | null;
readonly schema: DescMessage | null;
readonly headers: Headers;
readonly trailers: Headers;
readonly duration: number;
Expand Down Expand Up @@ -233,14 +281,33 @@ export class ConnectRpcResponseSuccessImpl<T>
readonly trailers: Headers;
readonly duration: number;
readonly data: T | null;
readonly raw: T | null;
readonly raw: unknown | null;

constructor(params: ConnectRpcResponseSuccessParams<T>) {
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;
}
}
}

Expand Down
71 changes: 71 additions & 0 deletions packages/probitas-client-connectrpc/response_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import {
assert,
assertEquals,
assertExists,
assertFalse,
assertInstanceOf,
} from "@std/assert";
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down
Loading