Skip to content

Commit a687a91

Browse files
authored
Convert rpc handler responses into a JSON-safe 'any' type (#203)
We pass directly to the google.protobuf.Value conversion function, which is fine, except that this is not as clever as JSON.stringify, and will treat objects like Date as empty objects. To match stringify, we should respect the toJSON() function which may be present on the value to be converted. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description
1 parent ae50697 commit a687a91

File tree

3 files changed

+95
-1
lines changed

3 files changed

+95
-1
lines changed

src/server/base_restate_server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { RpcContextImpl } from "../restate_context_impl";
4949
import { verifyAssumptions } from "../utils/assumpsions";
5050
import { TerminalError } from "../public_api";
5151
import { isEventHandler } from "../types/router";
52+
import { jsonSafeAny } from "../utils/utils";
5253

5354
export interface ServiceOpts {
5455
descriptor: ProtoMetadata;
@@ -175,7 +176,9 @@ export abstract class BaseRestateServer {
175176

176177
const decoder = RpcRequest.decode;
177178
const encoder = (message: RpcResponse) =>
178-
RpcResponse.encode(message).finish();
179+
RpcResponse.encode({
180+
response: jsonSafeAny("", message.response),
181+
}).finish();
179182

180183
const method = new GrpcServiceMethod<RpcRequest, RpcResponse>(
181184
route,

src/utils/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,26 @@ export function jsonDeserialize<T>(json: string): T {
6969
) as T;
7070
}
7171

72+
// When using google.protobuf.Value in RPC handler responses, we want to roughly match the behaviour of JSON.stringify
73+
// for example in converting Date objects to a UTC string
74+
export function jsonSafeAny(key: string, value: any): any {
75+
if (typeof value.toJSON == "function") {
76+
return value.toJSON(key) as any;
77+
} else if (globalThis.Array.isArray(value)) {
78+
// in place replace
79+
value.forEach((_, i) => (value[i] = jsonSafeAny(i.toString(), value[i])));
80+
return value;
81+
} else if (typeof value === "object") {
82+
Object.keys(value).forEach((key) => {
83+
value[key] = jsonSafeAny(key, value[key]);
84+
});
85+
return value;
86+
} else {
87+
// primitive that doesn't have a toJSON method, with no children
88+
return value;
89+
}
90+
}
91+
7292
export function printMessageAsJson(obj: any): string {
7393
const newObj = { ...(obj as Record<string, unknown>) };
7494
for (const [key, value] of Object.entries(newObj)) {

test/utils.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { describe, expect } from "@jest/globals";
1313
import {
1414
jsonDeserialize,
15+
jsonSafeAny,
1516
jsonSerialize,
1617
printMessageAsJson,
1718
} from "../src/utils/utils";
@@ -127,3 +128,73 @@ describe("rand", () => {
127128
expect(actual).toStrictEqual(expected);
128129
});
129130
});
131+
132+
describe("jsonSafeAny", () => {
133+
it("handles dates", () => {
134+
expect(jsonSafeAny("", new Date(1701878170682))).toStrictEqual(
135+
"2023-12-06T15:56:10.682Z"
136+
);
137+
expect(jsonSafeAny("", { date: new Date(1701878170682) })).toStrictEqual({
138+
date: "2023-12-06T15:56:10.682Z",
139+
});
140+
expect(
141+
jsonSafeAny("", {
142+
dates: [new Date(1701878170682), new Date(1701878170683)],
143+
})
144+
).toStrictEqual({
145+
dates: ["2023-12-06T15:56:10.682Z", "2023-12-06T15:56:10.683Z"],
146+
});
147+
});
148+
it("handles urls", () => {
149+
expect(jsonSafeAny("", new URL("https://restate.dev"))).toStrictEqual(
150+
"https://restate.dev/"
151+
);
152+
});
153+
it("handles patched BigInts", () => {
154+
// by default should do nothing
155+
expect(jsonSafeAny("", BigInt("9007199254740991"))).toStrictEqual(
156+
BigInt("9007199254740991")
157+
);
158+
(BigInt.prototype as any).toJSON = function () {
159+
return this.toString();
160+
};
161+
expect(jsonSafeAny("", BigInt("9007199254740991"))).toStrictEqual(
162+
"9007199254740991"
163+
);
164+
delete (BigInt.prototype as any).toJSON;
165+
});
166+
it("handles custom types", () => {
167+
const numberType = {
168+
toJSON(key: string): number {
169+
return 1;
170+
},
171+
};
172+
const stringType = {
173+
toJSON(key: string): string {
174+
return "foo";
175+
},
176+
};
177+
expect(jsonSafeAny("", numberType)).toStrictEqual(1);
178+
expect(jsonSafeAny("", stringType)).toStrictEqual("foo");
179+
});
180+
it("provides the correct key", () => {
181+
const keys: string[] = [];
182+
const typ = {
183+
toJSON(key: string): string {
184+
keys.push(key);
185+
return "";
186+
},
187+
};
188+
expect(jsonSafeAny("", typ)).toStrictEqual("");
189+
expect(jsonSafeAny("", { key: typ })).toStrictEqual({ key: "" });
190+
expect(jsonSafeAny("", { key: [typ] })).toStrictEqual({ key: [""] });
191+
expect(jsonSafeAny("", { key: [0, typ] })).toStrictEqual({ key: [0, ""] });
192+
expect(jsonSafeAny("", { key: [0, { key2: typ }] })).toStrictEqual({
193+
key: [0, { key2: "" }],
194+
});
195+
expect(jsonSafeAny("", { key: [0, { key2: [typ] }] })).toStrictEqual({
196+
key: [0, { key2: [""] }],
197+
});
198+
expect(keys).toStrictEqual(["", "key", "0", "1", "key2", "0"]);
199+
});
200+
});

0 commit comments

Comments
 (0)