diff --git a/src/json.test.ts b/src/json.test.ts index 3c14f65c..a8162707 100644 --- a/src/json.test.ts +++ b/src/json.test.ts @@ -1,9 +1,16 @@ import { - jsonrpc2, - isJsonRpcSuccess, - JsonRpcError, + assertIsJsonRpcFailure, + assertIsJsonRpcNotification, + assertIsJsonRpcRequest, + assertIsJsonRpcSuccess, + getJsonRpcIdValidator, isJsonRpcFailure, + isJsonRpcNotification, + isJsonRpcRequest, + isJsonRpcSuccess, isValidJson, + jsonrpc2, + JsonRpcError, } from '.'; const getError = () => { @@ -40,6 +47,88 @@ 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('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( @@ -62,6 +151,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( @@ -83,4 +192,98 @@ describe('json', () => { ).toBe(false); }); }); + + describe('assertIsJsonRpcFailure', () => { + it('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 1db35315..7ab35f9b 100644 --- a/src/json.ts +++ b/src/json.ts @@ -78,6 +78,59 @@ 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.'); + } +} + +/** + * 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. * @@ -109,10 +162,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,10 +175,19 @@ 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 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.'); + } +} + +/** * Type guard to narrow a JsonRpcResponse object to a failure (or success). * * @param response - The response object to check. @@ -141,3 +199,70 @@ export function isJsonRpcFailure( ): 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. + */ +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; +}