From 3e9d26b23293a0a4638e10d8b79671f056c25c38 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sun, 15 May 2022 18:36:01 -0700 Subject: [PATCH 1/3] Add more Json utils --- src/json.test.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++-- src/json.ts | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 3c14f65cf..20177fe31 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -1,9 +1,12 @@ import { - jsonrpc2, - isJsonRpcSuccess, - JsonRpcError, + assertIsJsonRpcFailure, + assertIsJsonRpcSuccess, + getJsonRpcIdValidator, isJsonRpcFailure, + isJsonRpcSuccess, isValidJson, + jsonrpc2, + JsonRpcError, } from '.'; const getError = () => { @@ -83,4 +86,112 @@ describe('json', () => { ).toBe(false); }); }); + + describe('assertIsJsonRpcSuccess', () => { + it('correctly identifies JSON-RPC response objects', () => { + ([{ result: 'success' }, { result: null }] as any[]).forEach((input) => { + expect(() => assertIsJsonRpcSuccess(input)).not.toThrow(); + }); + + ([{ error: new Error('foo') }, {}] as any[]).forEach((input) => { + expect(() => assertIsJsonRpcSuccess(input)).toThrow( + 'Not a successful JSON-RPC response.', + ); + }); + }); + }); + + describe('assertIsJsonRpcFailure', () => { + it('correctly identifies JSON-RPC response objects', () => { + ([{ error: 'failure' }, { error: null }] as any[]).forEach((input) => { + expect(() => assertIsJsonRpcFailure(input)).not.toThrow(); + }); + + ([{ result: 'success' }, {}] as any[]).forEach((input) => { + expect(() => assertIsJsonRpcFailure(input)).toThrow( + 'Not a failed JSON-RPC response.', + ); + }); + }); + }); + + describe('getJsonRpcIdValidator', () => { + const getInputs = () => { + return { + // invariant with respect to options + fractionString: { value: '1.2', expected: true }, + negativeInteger: { value: -1, expected: true }, + object: { value: {}, expected: false }, + positiveInteger: { value: 1, expected: true }, + string: { value: 'foo', expected: true }, + undefined: { value: undefined, expected: false }, + zero: { value: 0, expected: true }, + // variant with respect to options + emptyString: { value: '', expected: true }, + fraction: { value: 1.2, expected: false }, + null: { value: null, expected: true }, + }; + }; + + const validateAll = ( + validate: ReturnType, + inputs: ReturnType, + ) => { + for (const input of Object.values(inputs)) { + expect(validate(input.value)).toStrictEqual(input.expected); + } + }; + + it('performs as expected with default options', () => { + const inputs = getInputs(); + + // The default options are: + // permitEmptyString: true, + // permitFractions: false, + // permitNull: true, + expect(() => validateAll(getJsonRpcIdValidator(), inputs)).not.toThrow(); + }); + + it('performs as expected with "permitEmptyString: false"', () => { + const inputs = getInputs(); + inputs.emptyString.expected = false; + + expect(() => + validateAll( + getJsonRpcIdValidator({ + permitEmptyString: false, + }), + inputs, + ), + ).not.toThrow(); + }); + + it('performs as expected with "permitFractions: true"', () => { + const inputs = getInputs(); + inputs.fraction.expected = true; + + expect(() => + validateAll( + getJsonRpcIdValidator({ + permitFractions: true, + }), + inputs, + ), + ).not.toThrow(); + }); + + it('performs as expected with "permitNull: false"', () => { + const inputs = getInputs(); + inputs.null.expected = false; + + expect(() => + validateAll( + getJsonRpcIdValidator({ + permitNull: false, + }), + inputs, + ), + ).not.toThrow(); + }); + }); }); diff --git a/src/json.ts b/src/json.ts index 1db353155..bbd6c6963 100644 --- a/src/json.ts +++ b/src/json.ts @@ -141,3 +141,89 @@ export function isJsonRpcFailure( ): response is JsonRpcFailure { return hasProperty(response, 'error'); } + +/** + * ATTN: Assumes that only one of the `result` and `error` properties is + * present on the `response`, as guaranteed by e.g. `JsonRpcEngine.handle`. + * + * Type assertion to narrow a JsonRpcResponse object to a success (or failure). + * + * @param response - The response object to check. + */ +export function assertIsJsonRpcSuccess( + response: JsonRpcResponse, +): asserts response is JsonRpcSuccess { + if (!isJsonRpcSuccess(response)) { + throw new Error('Not a successful JSON-RPC response.'); + } +} + +/** + * ATTN: Assumes that only one of the `result` and `error` properties is + * present on the `response`, as guaranteed by e.g. `JsonRpcEngine.handle`. + * + * Type assertion to narrow a JsonRpcResponse object to a failure (or success). + * + * @param response - The response object to check. + */ +export function assertIsJsonRpcFailure( + response: JsonRpcResponse, +): asserts response is JsonRpcFailure { + if (!isJsonRpcFailure(response)) { + throw new Error('Not a failed JSON-RPC response.'); + } +} + +type JsonRpcValidatorOptions = { + permitEmptyString?: boolean; + permitFractions?: boolean; + permitNull?: boolean; +}; + +/** + * Gets a function for validating JSON-RPC request / response `id` values. + * + * By manipulating the options of this factory, you can control the behavior + * of the resulting validator for some edge cases. This is useful because e.g. + * `null` should sometimes but not always be permitted. + * + * Note that the empty string (`''`) is always permitted by the JSON-RPC + * specification, but that kind of sucks and you may want to forbid it in some + * instances anyway. + * + * For more details, see the + * [JSON-RPC Specification](https://www.jsonrpc.org/specification). + * + * @param options - An options object. + * @param options.permitEmptyString - Whether the empty string (i.e. `''`) + * should be treated as a valid ID. Default: `true` + * @param options.permitFractions - Whether fractional numbers (e.g. `1.2`) + * should be treated as valid IDs. Default: `false` + * @param options.permitNull - Whether `null` should be treated as a valid ID. + * Default: `true` + * @returns The JSON-RPC ID validator function. + */ +export function getJsonRpcIdValidator(options?: JsonRpcValidatorOptions) { + const { permitEmptyString, permitFractions, permitNull } = { + permitEmptyString: true, + permitFractions: false, + permitNull: true, + ...options, + }; + + /** + * Type guard for {@link JsonRpcId}. + * + * @param id - The JSON-RPC ID value to check. + * @returns Whether the given ID is valid per the options given to the + * factory. + */ + const isValidJsonRpcId = (id: unknown): id is JsonRpcId => { + return Boolean( + (typeof id === 'number' && (permitFractions || Number.isInteger(id))) || + (typeof id === 'string' && (permitEmptyString || id.length > 0)) || + (permitNull && id === null), + ); + }; + return isValidJsonRpcId; +} From 40c9e19784ab2506281ab95e74122a7625d4ca8f Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sun, 15 May 2022 21:27:14 -0700 Subject: [PATCH 2/3] Add notification type guards --- src/json.test.ts | 79 +++++++++++++++++++++++++++++++++++++++--------- src/json.ts | 65 +++++++++++++++++++++++---------------- 2 files changed, 103 insertions(+), 41 deletions(-) diff --git a/src/json.test.ts b/src/json.test.ts index 20177fe31..48a2f4822 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -1,8 +1,10 @@ import { assertIsJsonRpcFailure, + assertIsJsonRpcNotification, assertIsJsonRpcSuccess, getJsonRpcIdValidator, isJsonRpcFailure, + isJsonRpcNotification, isJsonRpcSuccess, isValidJson, jsonrpc2, @@ -43,6 +45,47 @@ describe('json', () => { }); }); + describe('isJsonRpcNotification', () => { + it('identifies a JSON-RPC notification', () => { + expect( + isJsonRpcNotification({ + jsonrpc: jsonrpc2, + method: 'foo', + }), + ).toBe(true); + }); + + it('identifies a JSON-RPC request', () => { + expect( + isJsonRpcNotification({ + jsonrpc: jsonrpc2, + id: 1, + method: 'foo', + }), + ).toBe(false); + }); + }); + + describe('assertIsJsonRpcNotification', () => { + it('identifies JSON-RPC notification objects', () => { + [ + { jsonrpc: jsonrpc2, method: 'foo' }, + { jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] }, + ].forEach((input) => { + expect(() => assertIsJsonRpcNotification(input)).not.toThrow(); + }); + + [ + { id: 1, jsonrpc: jsonrpc2, method: 'foo' }, + { id: 1, jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] }, + ].forEach((input) => { + expect(() => assertIsJsonRpcNotification(input)).toThrow( + 'Not a JSON-RPC notification.', + ); + }); + }); + }); + describe('isJsonRpcSuccess', () => { it('identifies a successful JSON-RPC response', () => { expect( @@ -65,6 +108,26 @@ describe('json', () => { }); }); + describe('assertIsJsonRpcSuccess', () => { + it('identifies JSON-RPC response objects', () => { + [ + { id: 1, jsonrpc: jsonrpc2, result: 'success' }, + { id: 1, jsonrpc: jsonrpc2, result: null }, + ].forEach((input) => { + expect(() => assertIsJsonRpcSuccess(input)).not.toThrow(); + }); + + [ + { id: 1, jsonrpc: jsonrpc2, error: getError() }, + { id: 1, jsonrpc: jsonrpc2, error: null as any }, + ].forEach((input) => { + expect(() => assertIsJsonRpcSuccess(input)).toThrow( + 'Not a successful JSON-RPC response.', + ); + }); + }); + }); + describe('isJsonRpcFailure', () => { it('identifies a failed JSON-RPC response', () => { expect( @@ -87,22 +150,8 @@ describe('json', () => { }); }); - describe('assertIsJsonRpcSuccess', () => { - it('correctly identifies JSON-RPC response objects', () => { - ([{ result: 'success' }, { result: null }] as any[]).forEach((input) => { - expect(() => assertIsJsonRpcSuccess(input)).not.toThrow(); - }); - - ([{ error: new Error('foo') }, {}] as any[]).forEach((input) => { - expect(() => assertIsJsonRpcSuccess(input)).toThrow( - 'Not a successful JSON-RPC response.', - ); - }); - }); - }); - describe('assertIsJsonRpcFailure', () => { - it('correctly identifies JSON-RPC response objects', () => { + it('identifies JSON-RPC response objects', () => { ([{ error: 'failure' }, { error: null }] as any[]).forEach((input) => { expect(() => assertIsJsonRpcFailure(input)).not.toThrow(); }); diff --git a/src/json.ts b/src/json.ts index bbd6c6963..fc6bff151 100644 --- a/src/json.ts +++ b/src/json.ts @@ -78,6 +78,33 @@ export type JsonRpcNotification = { params?: Params; }; +/** + * Type guard to narrow a JSON-RPC request or notification object to a + * notification. + * + * @param requestOrNotification - The JSON-RPC request or notification to check. + * @returns Whether the specified JSON-RPC message is a notification. + */ +export function isJsonRpcNotification( + requestOrNotification: JsonRpcNotification | JsonRpcRequest, +): requestOrNotification is JsonRpcNotification { + return !hasProperty(requestOrNotification, 'id'); +} + +/** + * Assertion type guard to narrow a JSON-RPC request or notification object to a + * notification. + * + * @param requestOrNotification - The JSON-RPC request or notification to check. + */ +export function assertIsJsonRpcNotification( + requestOrNotification: JsonRpcNotification | JsonRpcRequest, +): asserts requestOrNotification is JsonRpcNotification { + if (!isJsonRpcNotification(requestOrNotification)) { + throw new Error('Not a JSON-RPC notification.'); + } +} + /** * A successful JSON-RPC response object. * @@ -109,10 +136,6 @@ export type JsonRpcResponse = | JsonRpcFailure; /** - * ATTN: Assumes that only one of the `result` and `error` properties is - * present on the `response`, as guaranteed by e.g. - * [`JsonRpcEngine.handle`](https://github.com/MetaMask/json-rpc-engine/blob/main/src/JsonRpcEngine.ts). - * * Type guard to narrow a JsonRpcResponse object to a success (or failure). * * @param response - The response object to check. @@ -126,26 +149,6 @@ export function isJsonRpcSuccess( } /** - * ATTN: Assumes that only one of the `result` and `error` properties is - * present on the `response`, as guaranteed by e.g. - * [`JsonRpcEngine.handle`](https://github.com/MetaMask/json-rpc-engine/blob/main/src/JsonRpcEngine.ts). - * - * Type guard to narrow a JsonRpcResponse object to a failure (or success). - * - * @param response - The response object to check. - * @returns Whether the response object is a failure, i.e. has an `error` - * property. - */ -export function isJsonRpcFailure( - response: JsonRpcResponse, -): response is JsonRpcFailure { - return hasProperty(response, 'error'); -} - -/** - * ATTN: Assumes that only one of the `result` and `error` properties is - * present on the `response`, as guaranteed by e.g. `JsonRpcEngine.handle`. - * * Type assertion to narrow a JsonRpcResponse object to a success (or failure). * * @param response - The response object to check. @@ -159,9 +162,19 @@ export function assertIsJsonRpcSuccess( } /** - * ATTN: Assumes that only one of the `result` and `error` properties is - * present on the `response`, as guaranteed by e.g. `JsonRpcEngine.handle`. + * Type guard to narrow a JsonRpcResponse object to a failure (or success). * + * @param response - The response object to check. + * @returns Whether the response object is a failure, i.e. has an `error` + * property. + */ +export function isJsonRpcFailure( + response: JsonRpcResponse, +): response is JsonRpcFailure { + return hasProperty(response, 'error'); +} + +/** * Type assertion to narrow a JsonRpcResponse object to a failure (or success). * * @param response - The response object to check. From ddf1b87a4915d0efc7a3e4fe565c79b89925d1dc Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sun, 15 May 2022 21:29:57 -0700 Subject: [PATCH 3/3] Add request type guards --- src/json.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/json.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/json.test.ts b/src/json.test.ts index 48a2f4822..a81627075 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -1,10 +1,12 @@ import { assertIsJsonRpcFailure, assertIsJsonRpcNotification, + assertIsJsonRpcRequest, assertIsJsonRpcSuccess, getJsonRpcIdValidator, isJsonRpcFailure, isJsonRpcNotification, + isJsonRpcRequest, isJsonRpcSuccess, isValidJson, jsonrpc2, @@ -86,6 +88,47 @@ describe('json', () => { }); }); + describe('isJsonRpcRequest', () => { + it('identifies a JSON-RPC notification', () => { + expect( + isJsonRpcRequest({ + id: 1, + jsonrpc: jsonrpc2, + method: 'foo', + }), + ).toBe(true); + }); + + it('identifies a JSON-RPC request', () => { + expect( + isJsonRpcRequest({ + jsonrpc: jsonrpc2, + method: 'foo', + }), + ).toBe(false); + }); + }); + + describe('assertIsJsonRpcRequest', () => { + it('identifies JSON-RPC notification objects', () => { + [ + { id: 1, jsonrpc: jsonrpc2, method: 'foo' }, + { id: 1, jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] }, + ].forEach((input) => { + expect(() => assertIsJsonRpcRequest(input)).not.toThrow(); + }); + + [ + { jsonrpc: jsonrpc2, method: 'foo' }, + { jsonrpc: jsonrpc2, method: 'bar', params: ['baz'] }, + ].forEach((input) => { + expect(() => assertIsJsonRpcRequest(input)).toThrow( + 'Not a JSON-RPC request.', + ); + }); + }); + }); + describe('isJsonRpcSuccess', () => { it('identifies a successful JSON-RPC response', () => { expect( diff --git a/src/json.ts b/src/json.ts index fc6bff151..7ab35f9bc 100644 --- a/src/json.ts +++ b/src/json.ts @@ -105,6 +105,32 @@ export function assertIsJsonRpcNotification( } } +/** + * Type guard to narrow a JSON-RPC request or notification object to a request. + * + * @param requestOrNotification - The JSON-RPC request or notification to check. + * @returns Whether the specified JSON-RPC message is a request. + */ +export function isJsonRpcRequest( + requestOrNotification: JsonRpcNotification | JsonRpcRequest, +): requestOrNotification is JsonRpcRequest { + return hasProperty(requestOrNotification, 'id'); +} + +/** + * Assertion type guard to narrow a JSON-RPC request or notification object to a + * request. + * + * @param requestOrNotification - The JSON-RPC request or notification to check. + */ +export function assertIsJsonRpcRequest( + requestOrNotification: JsonRpcNotification | JsonRpcRequest, +): asserts requestOrNotification is JsonRpcRequest { + if (!isJsonRpcRequest(requestOrNotification)) { + throw new Error('Not a JSON-RPC request.'); + } +} + /** * A successful JSON-RPC response object. *