Skip to content

Commit

Permalink
Add more JSON utils (#8)
Browse files Browse the repository at this point in the history
`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.
  • Loading branch information
rekmarks authored May 16, 2022
1 parent a653900 commit 7c5689c
Show file tree
Hide file tree
Showing 2 changed files with 338 additions and 10 deletions.
209 changes: 206 additions & 3 deletions src/json.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {
jsonrpc2,
isJsonRpcSuccess,
JsonRpcError,
assertIsJsonRpcFailure,
assertIsJsonRpcNotification,
assertIsJsonRpcRequest,
assertIsJsonRpcSuccess,
getJsonRpcIdValidator,
isJsonRpcFailure,
isJsonRpcNotification,
isJsonRpcRequest,
isJsonRpcSuccess,
isValidJson,
jsonrpc2,
JsonRpcError,
} from '.';

const getError = () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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<typeof getJsonRpcIdValidator>,
inputs: ReturnType<typeof getInputs>,
) => {
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();
});
});
});
139 changes: 132 additions & 7 deletions src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,59 @@ export type JsonRpcNotification<Params> = {
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<T>(
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
): requestOrNotification is JsonRpcNotification<T> {
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<T>(
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
): asserts requestOrNotification is JsonRpcNotification<T> {
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<T>(
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
): requestOrNotification is JsonRpcRequest<T> {
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<T>(
requestOrNotification: JsonRpcNotification<T> | JsonRpcRequest<T>,
): asserts requestOrNotification is JsonRpcRequest<T> {
if (!isJsonRpcRequest(requestOrNotification)) {
throw new Error('Not a JSON-RPC request.');
}
}

/**
* A successful JSON-RPC response object.
*
Expand Down Expand Up @@ -109,10 +162,6 @@ export type JsonRpcResponse<Result = unknown> =
| 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.
Expand All @@ -126,10 +175,19 @@ export function isJsonRpcSuccess<Result>(
}

/**
* 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<T>(
response: JsonRpcResponse<T>,
): asserts response is JsonRpcSuccess<T> {
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.
Expand All @@ -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<unknown>,
): 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;
}

0 comments on commit 7c5689c

Please sign in to comment.