Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,17 @@ describe('SafeJsonRpcServer', () => {
it('calls an RPC function with no inputs nor outputs', async () => {
const response = await send({ method: 'clear', params: [] });
expect(response.status).toBe(200);
expect(response.text).toEqual(JSON.stringify({ jsonrpc }));
expect(response.text).toEqual(JSON.stringify({ jsonrpc, result: null }));
expect(testState.notes).toEqual([]);
});

it('returns an explicit null result (not an omitted field) when a handler returns undefined', async () => {
const response = await send({ method: 'getNote', params: [99] });
expect(response.status).toBe(200);
expect(response.text).toEqual(JSON.stringify({ jsonrpc, result: null }));
expect(JSON.parse(response.text)).toHaveProperty('result', null);
});

it('calls an RPC function that returns a primitive object and a bigint', async () => {
const response = await send({ method: 'getStatus', params: [] });
expect(response.status).toBe(200);
Expand Down Expand Up @@ -185,7 +192,7 @@ describe('SafeJsonRpcServer', () => {
expect(resp.text).toEqual(
JSON.stringify([
{ jsonrpc: '2.0', id: 42, result: { status: 'ok', count: '2' } },
{ jsonrpc: '2.0', id: 43 },
{ jsonrpc: '2.0', id: 43, result: null },
]),
);
});
Expand All @@ -210,7 +217,7 @@ describe('SafeJsonRpcServer', () => {
expect(resp.text).toEqual(
JSON.stringify([
{ jsonrpc: '2.0', id: 42, error: { code: -32601, message: 'Method not found: toString' } },
{ jsonrpc: '2.0', id: 43 },
{ jsonrpc: '2.0', id: 43, result: null },
]),
);
});
Expand All @@ -221,7 +228,7 @@ describe('SafeJsonRpcServer', () => {
expect(resp.status).toEqual(200);
expect(resp.text).toEqual(
JSON.stringify([
{ jsonrpc: '2.0', id: 43 },
{ jsonrpc: '2.0', id: 43, result: null },
{ jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request' }, id: null },
]),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ export class SafeJsonRpcServer {
result = await this.proxy.call(method, params);
}

return { jsonrpc, id, result };
// Coerce an undefined return value to null so the response always carries a `result` key.
// JSON.stringify drops undefined-valued keys, which would otherwise produce a JSON-RPC
// response with neither `result` nor `error` — a spec violation that leaves callers unable
// to distinguish "not found" from a malformed response.
return { jsonrpc, id, result: result ?? null };
} catch (err: any) {
if (err && err instanceof ZodError) {
const message = err.issues.map(e => `${e.message} (${e.path.join('.')})`).join('. ') || 'Validation error';
Expand Down
32 changes: 32 additions & 0 deletions yarn-project/foundation/src/json-rpc/test/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { JsonRpcFetch } from '../client/fetch.js';
import { createSafeJsonRpcClient } from '../client/safe_json_rpc_client.js';
import { TestNote, TestState, type TestStateApi, TestStateSchema } from '../fixtures/test_state.js';
import { startHttpRpcServer } from '../server/safe_json_rpc_server.js';
Expand Down Expand Up @@ -125,6 +126,37 @@ describe('JsonRpc integration', () => {
});
});

describe('null results against optional schemas', () => {
let client: TestStateApi;
let url: string;

beforeEach(async () => {
({ server, httpServer, client, url } = await createJsonRpcTestSetup(testState, TestStateSchema));
});

it('client accepts the explicit null result the server sends for an undefined return', async () => {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'getNote', params: [10] }),
});
expect(await response.json()).toEqual({ jsonrpc: '2.0', id: 0, result: null });

await expect(client.getNote(10)).resolves.toBeUndefined();
});

it('client resolves undefined when any server returns null for an optional result schema', async () => {
const nullResultFetch: JsonRpcFetch = (_host, body) =>
Promise.resolve({
response: body.map((req: { id: number }) => ({ jsonrpc: '2.0', id: req.id, result: null })),
headers: { get: () => undefined },
});
const nullClient = createSafeJsonRpcClient<TestStateApi>(url, TestStateSchema, { fetch: nullResultFetch });

await expect(nullClient.getNote(0)).resolves.toBeUndefined();
});
});

describe('namespaced', () => {
let lettersState: TestState;
let numbersState: TestState;
Expand Down
Loading