Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add more JSON utils #8

Merged
merged 3 commits into from
May 16, 2022
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
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;
}