Skip to content

Commit b9754bb

Browse files
authored
Add more JSON utils (#8)
`json-rpc-engine` recently added some generic JSON utils. This PR copies them to this package so that they can be deleted from `json-rpc-engine`. The tests and utilities are copied over exactly as they were implemented in MetaMask/json-rpc-engine#102.
1 parent 2e59ee6 commit b9754bb

File tree

2 files changed

+338
-10
lines changed

2 files changed

+338
-10
lines changed

merged-packages/utils/src/json.test.ts

Lines changed: 206 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import {
2-
jsonrpc2,
3-
isJsonRpcSuccess,
4-
JsonRpcError,
2+
assertIsJsonRpcFailure,
3+
assertIsJsonRpcNotification,
4+
assertIsJsonRpcRequest,
5+
assertIsJsonRpcSuccess,
6+
getJsonRpcIdValidator,
57
isJsonRpcFailure,
8+
isJsonRpcNotification,
9+
isJsonRpcRequest,
10+
isJsonRpcSuccess,
611
isValidJson,
12+
jsonrpc2,
13+
JsonRpcError,
714
} from '.';
815

916
const getError = () => {
@@ -40,6 +47,88 @@ describe('json', () => {
4047
});
4148
});
4249

50+
describe('isJsonRpcNotification', () => {
51+
it('identifies a JSON-RPC notification', () => {
52+
expect(
53+
isJsonRpcNotification({
54+
jsonrpc: jsonrpc2,
55+
method: 'foo',
56+
}),
57+
).toBe(true);
58+
});
59+
60+
it('identifies a JSON-RPC request', () => {
61+
expect(
62+
isJsonRpcNotification({
63+
jsonrpc: jsonrpc2,
64+
id: 1,
65+
method: 'foo',
66+
}),
67+
).toBe(false);
68+
});
69+
});
70+
71+
describe('assertIsJsonRpcNotification', () => {
72+
it('identifies JSON-RPC notification objects', () => {
73+
[
74+
{ jsonrpc: jsonrpc2, method: 'foo' },
75+
{ jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] },
76+
].forEach((input) => {
77+
expect(() => assertIsJsonRpcNotification(input)).not.toThrow();
78+
});
79+
80+
[
81+
{ id: 1, jsonrpc: jsonrpc2, method: 'foo' },
82+
{ id: 1, jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] },
83+
].forEach((input) => {
84+
expect(() => assertIsJsonRpcNotification(input)).toThrow(
85+
'Not a JSON-RPC notification.',
86+
);
87+
});
88+
});
89+
});
90+
91+
describe('isJsonRpcRequest', () => {
92+
it('identifies a JSON-RPC notification', () => {
93+
expect(
94+
isJsonRpcRequest({
95+
id: 1,
96+
jsonrpc: jsonrpc2,
97+
method: 'foo',
98+
}),
99+
).toBe(true);
100+
});
101+
102+
it('identifies a JSON-RPC request', () => {
103+
expect(
104+
isJsonRpcRequest({
105+
jsonrpc: jsonrpc2,
106+
method: 'foo',
107+
}),
108+
).toBe(false);
109+
});
110+
});
111+
112+
describe('assertIsJsonRpcRequest', () => {
113+
it('identifies JSON-RPC notification objects', () => {
114+
[
115+
{ id: 1, jsonrpc: jsonrpc2, method: 'foo' },
116+
{ id: 1, jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] },
117+
].forEach((input) => {
118+
expect(() => assertIsJsonRpcRequest(input)).not.toThrow();
119+
});
120+
121+
[
122+
{ jsonrpc: jsonrpc2, method: 'foo' },
123+
{ jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] },
124+
].forEach((input) => {
125+
expect(() => assertIsJsonRpcRequest(input)).toThrow(
126+
'Not a JSON-RPC request.',
127+
);
128+
});
129+
});
130+
});
131+
43132
describe('isJsonRpcSuccess', () => {
44133
it('identifies a successful JSON-RPC response', () => {
45134
expect(
@@ -62,6 +151,26 @@ describe('json', () => {
62151
});
63152
});
64153

154+
describe('assertIsJsonRpcSuccess', () => {
155+
it('identifies JSON-RPC response objects', () => {
156+
[
157+
{ id: 1, jsonrpc: jsonrpc2, result: 'success' },
158+
{ id: 1, jsonrpc: jsonrpc2, result: null },
159+
].forEach((input) => {
160+
expect(() => assertIsJsonRpcSuccess(input)).not.toThrow();
161+
});
162+
163+
[
164+
{ id: 1, jsonrpc: jsonrpc2, error: getError() },
165+
{ id: 1, jsonrpc: jsonrpc2, error: null as any },
166+
].forEach((input) => {
167+
expect(() => assertIsJsonRpcSuccess(input)).toThrow(
168+
'Not a successful JSON-RPC response.',
169+
);
170+
});
171+
});
172+
});
173+
65174
describe('isJsonRpcFailure', () => {
66175
it('identifies a failed JSON-RPC response', () => {
67176
expect(
@@ -83,4 +192,98 @@ describe('json', () => {
83192
).toBe(false);
84193
});
85194
});
195+
196+
describe('assertIsJsonRpcFailure', () => {
197+
it('identifies JSON-RPC response objects', () => {
198+
([{ error: 'failure' }, { error: null }] as any[]).forEach((input) => {
199+
expect(() => assertIsJsonRpcFailure(input)).not.toThrow();
200+
});
201+
202+
([{ result: 'success' }, {}] as any[]).forEach((input) => {
203+
expect(() => assertIsJsonRpcFailure(input)).toThrow(
204+
'Not a failed JSON-RPC response.',
205+
);
206+
});
207+
});
208+
});
209+
210+
describe('getJsonRpcIdValidator', () => {
211+
const getInputs = () => {
212+
return {
213+
// invariant with respect to options
214+
fractionString: { value: '1.2', expected: true },
215+
negativeInteger: { value: -1, expected: true },
216+
object: { value: {}, expected: false },
217+
positiveInteger: { value: 1, expected: true },
218+
string: { value: 'foo', expected: true },
219+
undefined: { value: undefined, expected: false },
220+
zero: { value: 0, expected: true },
221+
// variant with respect to options
222+
emptyString: { value: '', expected: true },
223+
fraction: { value: 1.2, expected: false },
224+
null: { value: null, expected: true },
225+
};
226+
};
227+
228+
const validateAll = (
229+
validate: ReturnType<typeof getJsonRpcIdValidator>,
230+
inputs: ReturnType<typeof getInputs>,
231+
) => {
232+
for (const input of Object.values(inputs)) {
233+
expect(validate(input.value)).toStrictEqual(input.expected);
234+
}
235+
};
236+
237+
it('performs as expected with default options', () => {
238+
const inputs = getInputs();
239+
240+
// The default options are:
241+
// permitEmptyString: true,
242+
// permitFractions: false,
243+
// permitNull: true,
244+
expect(() => validateAll(getJsonRpcIdValidator(), inputs)).not.toThrow();
245+
});
246+
247+
it('performs as expected with "permitEmptyString: false"', () => {
248+
const inputs = getInputs();
249+
inputs.emptyString.expected = false;
250+
251+
expect(() =>
252+
validateAll(
253+
getJsonRpcIdValidator({
254+
permitEmptyString: false,
255+
}),
256+
inputs,
257+
),
258+
).not.toThrow();
259+
});
260+
261+
it('performs as expected with "permitFractions: true"', () => {
262+
const inputs = getInputs();
263+
inputs.fraction.expected = true;
264+
265+
expect(() =>
266+
validateAll(
267+
getJsonRpcIdValidator({
268+
permitFractions: true,
269+
}),
270+
inputs,
271+
),
272+
).not.toThrow();
273+
});
274+
275+
it('performs as expected with "permitNull: false"', () => {
276+
const inputs = getInputs();
277+
inputs.null.expected = false;
278+
279+
expect(() =>
280+
validateAll(
281+
getJsonRpcIdValidator({
282+
permitNull: false,
283+
}),
284+
inputs,
285+
),
286+
).not.toThrow();
287+
});
288+
});
86289
});

merged-packages/utils/src/json.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,59 @@ export type JsonRpcNotification<Params> = {
7878
params?: Params;
7979
};
8080

81+
/**
82+
* Type guard to narrow a JSON-RPC request or notification object to a
83+
* notification.
84+
*
85+
* @param requestOrNotification - The JSON-RPC request or notification to check.
86+
* @returns Whether the specified JSON-RPC message is a notification.
87+
*/
88+
export function isJsonRpcNotification<T>(
89+
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
90+
): requestOrNotification is JsonRpcNotification<T> {
91+
return !hasProperty(requestOrNotification, 'id');
92+
}
93+
94+
/**
95+
* Assertion type guard to narrow a JSON-RPC request or notification object to a
96+
* notification.
97+
*
98+
* @param requestOrNotification - The JSON-RPC request or notification to check.
99+
*/
100+
export function assertIsJsonRpcNotification<T>(
101+
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
102+
): asserts requestOrNotification is JsonRpcNotification<T> {
103+
if (!isJsonRpcNotification(requestOrNotification)) {
104+
throw new Error('Not a JSON-RPC notification.');
105+
}
106+
}
107+
108+
/**
109+
* Type guard to narrow a JSON-RPC request or notification object to a request.
110+
*
111+
* @param requestOrNotification - The JSON-RPC request or notification to check.
112+
* @returns Whether the specified JSON-RPC message is a request.
113+
*/
114+
export function isJsonRpcRequest<T>(
115+
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
116+
): requestOrNotification is JsonRpcRequest<T> {
117+
return hasProperty(requestOrNotification, 'id');
118+
}
119+
120+
/**
121+
* Assertion type guard to narrow a JSON-RPC request or notification object to a
122+
* request.
123+
*
124+
* @param requestOrNotification - The JSON-RPC request or notification to check.
125+
*/
126+
export function assertIsJsonRpcRequest<T>(
127+
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
128+
): asserts requestOrNotification is JsonRpcRequest<T> {
129+
if (!isJsonRpcRequest(requestOrNotification)) {
130+
throw new Error('Not a JSON-RPC request.');
131+
}
132+
}
133+
81134
/**
82135
* A successful JSON-RPC response object.
83136
*
@@ -109,10 +162,6 @@ export type JsonRpcResponse<Result = unknown> =
109162
| JsonRpcFailure;
110163

111164
/**
112-
* ATTN: Assumes that only one of the `result` and `error` properties is
113-
* present on the `response`, as guaranteed by e.g.
114-
* [`JsonRpcEngine.handle`](https://github.com/MetaMask/json-rpc-engine/blob/main/src/JsonRpcEngine.ts).
115-
*
116165
* Type guard to narrow a JsonRpcResponse object to a success (or failure).
117166
*
118167
* @param response - The response object to check.
@@ -126,10 +175,19 @@ export function isJsonRpcSuccess<Result>(
126175
}
127176

128177
/**
129-
* ATTN: Assumes that only one of the `result` and `error` properties is
130-
* present on the `response`, as guaranteed by e.g.
131-
* [`JsonRpcEngine.handle`](https://github.com/MetaMask/json-rpc-engine/blob/main/src/JsonRpcEngine.ts).
178+
* Type assertion to narrow a JsonRpcResponse object to a success (or failure).
132179
*
180+
* @param response - The response object to check.
181+
*/
182+
export function assertIsJsonRpcSuccess<T>(
183+
response: JsonRpcResponse<T>,
184+
): asserts response is JsonRpcSuccess<T> {
185+
if (!isJsonRpcSuccess(response)) {
186+
throw new Error('Not a successful JSON-RPC response.');
187+
}
188+
}
189+
190+
/**
133191
* Type guard to narrow a JsonRpcResponse object to a failure (or success).
134192
*
135193
* @param response - The response object to check.
@@ -141,3 +199,70 @@ export function isJsonRpcFailure(
141199
): response is JsonRpcFailure {
142200
return hasProperty(response, 'error');
143201
}
202+
203+
/**
204+
* Type assertion to narrow a JsonRpcResponse object to a failure (or success).
205+
*
206+
* @param response - The response object to check.
207+
*/
208+
export function assertIsJsonRpcFailure(
209+
response: JsonRpcResponse<unknown>,
210+
): asserts response is JsonRpcFailure {
211+
if (!isJsonRpcFailure(response)) {
212+
throw new Error('Not a failed JSON-RPC response.');
213+
}
214+
}
215+
216+
type JsonRpcValidatorOptions = {
217+
permitEmptyString?: boolean;
218+
permitFractions?: boolean;
219+
permitNull?: boolean;
220+
};
221+
222+
/**
223+
* Gets a function for validating JSON-RPC request / response `id` values.
224+
*
225+
* By manipulating the options of this factory, you can control the behavior
226+
* of the resulting validator for some edge cases. This is useful because e.g.
227+
* `null` should sometimes but not always be permitted.
228+
*
229+
* Note that the empty string (`''`) is always permitted by the JSON-RPC
230+
* specification, but that kind of sucks and you may want to forbid it in some
231+
* instances anyway.
232+
*
233+
* For more details, see the
234+
* [JSON-RPC Specification](https://www.jsonrpc.org/specification).
235+
*
236+
* @param options - An options object.
237+
* @param options.permitEmptyString - Whether the empty string (i.e. `''`)
238+
* should be treated as a valid ID. Default: `true`
239+
* @param options.permitFractions - Whether fractional numbers (e.g. `1.2`)
240+
* should be treated as valid IDs. Default: `false`
241+
* @param options.permitNull - Whether `null` should be treated as a valid ID.
242+
* Default: `true`
243+
* @returns The JSON-RPC ID validator function.
244+
*/
245+
export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) {
246+
const { permitEmptyString, permitFractions, permitNull } = {
247+
permitEmptyString: true,
248+
permitFractions: false,
249+
permitNull: true,
250+
...options,
251+
};
252+
253+
/**
254+
* Type guard for {@link JsonRpcId}.
255+
*
256+
* @param id - The JSON-RPC ID value to check.
257+
* @returns Whether the given ID is valid per the options given to the
258+
* factory.
259+
*/
260+
const isValidJsonRpcId = (id: unknown): id is JsonRpcId => {
261+
return Boolean(
262+
(typeof id === 'number' && (permitFractions || Number.isInteger(id))) ||
263+
(typeof id === 'string' && (permitEmptyString || id.length > 0)) ||
264+
(permitNull && id === null),
265+
);
266+
};
267+
return isValidJsonRpcId;
268+
}

0 commit comments

Comments
 (0)