Skip to content

Commit 8fad51a

Browse files
committed
Add JSON-RPC matcher & handler methods
Matcher is just a convenient alias for a flexible JSON matcher, but the handlers provide specific logic, since they need to mirror the id from the incoming request.
1 parent 9b3cca6 commit 8fad51a

File tree

5 files changed

+267
-2
lines changed

5 files changed

+267
-2
lines changed

src/mockttp.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,18 @@ export interface Mockttp {
309309
*/
310310
forOptions(url?: string | RegExp): RequestRuleBuilder;
311311

312+
/**
313+
* Match JSON-RPC requests, optionally matching a given method and/or params.
314+
*
315+
* If no method or params are specified, this will match all JSON-RPC requests.
316+
*
317+
* Params are matched flexibly, using the same logic as .withJsonBodyIncluding(),
318+
* so only the included fields are checked and other extra fields are ignored
319+
*
320+
* @category Mock HTTP requests
321+
*/
322+
forJsonRpcRequest(match?: { method?: string, params?: any }): RequestRuleBuilder;
323+
312324
/**
313325
* Get a builder for a mock rule that will match all websocket connections.
314326
*
@@ -792,6 +804,15 @@ export abstract class AbstractMockttp {
792804
return new RequestRuleBuilder(Method.OPTIONS, url, this.addRequestRule);
793805
}
794806

807+
forJsonRpcRequest(match: { method?: string, params?: any } = {}) {
808+
return new RequestRuleBuilder(this.addRequestRule)
809+
.withJsonBodyIncluding({
810+
jsonrpc: '2.0',
811+
...(match.method ? { method: match.method } : {}),
812+
...(match.params ? { params: match.params } : {})
813+
});
814+
}
815+
795816
forAnyWebSocket(): WebSocketRuleBuilder {
796817
return new WebSocketRuleBuilder(this.addWebSocketRule);
797818
}

src/rules/requests/request-handler-definitions.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1029,12 +1029,37 @@ export class TimeoutHandlerDefinition extends Serializable implements RequestHan
10291029
}
10301030
}
10311031

1032+
export class JsonRpcResponseHandlerDefinition extends Serializable implements RequestHandlerDefinition {
1033+
readonly type = 'json-rpc-response';
1034+
1035+
constructor(
1036+
public readonly result:
1037+
| { result: any, error?: undefined }
1038+
| { error: any, result?: undefined }
1039+
) {
1040+
super();
1041+
1042+
if (!('result' in result) && !('error' in result)) {
1043+
throw new Error('JSON-RPC response must be either a result or an error');
1044+
}
1045+
}
1046+
1047+
explain() {
1048+
const resultType = 'result' in this.result
1049+
? 'result'
1050+
: 'error';
1051+
1052+
return `send a fixed JSON-RPC ${resultType} of ${JSON.stringify(this.result[resultType])}`;
1053+
}
1054+
}
1055+
10321056
export const HandlerDefinitionLookup = {
10331057
'simple': SimpleHandlerDefinition,
10341058
'callback': CallbackHandlerDefinition,
10351059
'stream': StreamHandlerDefinition,
10361060
'file': FileHandlerDefinition,
10371061
'passthrough': PassThroughHandlerDefinition,
10381062
'close-connection': CloseConnectionHandlerDefinition,
1039-
'timeout': TimeoutHandlerDefinition
1063+
'timeout': TimeoutHandlerDefinition,
1064+
'json-rpc-response': JsonRpcResponseHandlerDefinition
10401065
}

src/rules/requests/request-handlers.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
FileHandlerDefinition,
8181
ForwardingOptions,
8282
HandlerDefinitionLookup,
83+
JsonRpcResponseHandlerDefinition,
8384
PassThroughHandlerDefinition,
8485
PassThroughHandlerOptions,
8586
PassThroughLookupOptions,
@@ -1080,12 +1081,34 @@ export class TimeoutHandler extends TimeoutHandlerDefinition {
10801081
}
10811082
}
10821083

1084+
export class JsonRpcResponseHandler extends JsonRpcResponseHandlerDefinition {
1085+
async handle(request: OngoingRequest, response: OngoingResponse) {
1086+
const data: any = await request.body.asJson()
1087+
.catch(() => {}); // Handle parsing errors with the check below
1088+
1089+
if (!data || data.jsonrpc !== '2.0' || !('id' in data)) { // N.B. id can be null
1090+
throw new Error("Can't send a JSON-RPC response to an invalid JSON-RPC request");
1091+
}
1092+
1093+
response.writeHead(200, {
1094+
'content-type': 'application/json'
1095+
});
1096+
1097+
response.end(JSON.stringify({
1098+
jsonrpc: '2.0',
1099+
id: data.id,
1100+
...this.result
1101+
}));
1102+
}
1103+
}
1104+
10831105
export const HandlerLookup: typeof HandlerDefinitionLookup = {
10841106
'simple': SimpleHandler,
10851107
'callback': CallbackHandler,
10861108
'stream': StreamHandler,
10871109
'file': FileHandler,
10881110
'passthrough': PassThroughHandler,
10891111
'close-connection': CloseConnectionHandler,
1090-
'timeout': TimeoutHandler
1112+
'timeout': TimeoutHandler,
1113+
'json-rpc-response': JsonRpcResponseHandler
10911114
}

src/rules/requests/request-rule-builder.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
TimeoutHandlerDefinition,
1515
PassThroughHandlerOptions,
1616
FileHandlerDefinition,
17+
JsonRpcResponseHandlerDefinition,
1718
} from "./request-handler-definitions";
1819
import { MaybePromise } from "../../util/type-utils";
1920
import { byteLength } from "../../util/util";
@@ -395,4 +396,36 @@ export class RequestRuleBuilder extends BaseRuleBuilder {
395396

396397
return this.addRule(rule);
397398
}
399+
400+
/**
401+
* Send a successful JSON-RPC response to a JSON-RPC request. The response data
402+
* can be any JSON-serializable value. If a matching request is received that
403+
* is not a valid JSON-RPC request, it will be rejected with an HTTP error.
404+
*
405+
* @category Responses
406+
*/
407+
thenSendJsonRpcResult(result: any) {
408+
const rule = {
409+
...this.buildBaseRuleData(),
410+
handler: new JsonRpcResponseHandlerDefinition({ result })
411+
};
412+
413+
return this.addRule(rule);
414+
}
415+
416+
/**
417+
* Send a failing error JSON-RPC response to a JSON-RPC request. The error data
418+
* can be any JSON-serializable value. If a matching request is received that
419+
* is not a valid JSON-RPC request, it will be rejected with an HTTP error.
420+
*
421+
* @category Responses
422+
*/
423+
thenSendJsonRpcError(error: any) {
424+
const rule = {
425+
...this.buildBaseRuleData(),
426+
handler: new JsonRpcResponseHandlerDefinition({ error })
427+
};
428+
429+
return this.addRule(rule);
430+
}
398431
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { getLocal } from "../../..";
2+
import { expect, fetch } from "../../test-utils";
3+
4+
describe("JSON-RPC methods", () => {
5+
6+
const server = getLocal();
7+
8+
beforeEach(() => server.start());
9+
afterEach(() => server.stop());
10+
11+
it("can match and mock a successful JSON-RPC request", async () => {
12+
await server.forJsonRpcRequest()
13+
.thenSendJsonRpcResult({ value: 'mock-result' });
14+
15+
const response = await fetch(server.url, {
16+
method: 'POST',
17+
headers: { 'content-type': 'application/json' },
18+
body: JSON.stringify({
19+
jsonrpc: '2.0',
20+
id: '1234',
21+
method: 'getValue',
22+
params: []
23+
})
24+
});
25+
26+
expect(response.ok).to.equal(true);
27+
expect(await response.json()).to.deep.equal({
28+
jsonrpc: '2.0',
29+
id: '1234',
30+
result: { value: 'mock-result' }
31+
});
32+
});
33+
34+
it("can match against specific JSON-RPC methods", async () => {
35+
await server.forJsonRpcRequest({
36+
method: 'getValue'
37+
}).thenSendJsonRpcResult({ value: 'mock-result' });
38+
39+
const matchingResponse = await fetch(server.url, {
40+
method: 'POST',
41+
headers: { 'content-type': 'application/json' },
42+
body: JSON.stringify({
43+
jsonrpc: '2.0',
44+
id: '1',
45+
method: 'getValue',
46+
params: []
47+
})
48+
});
49+
50+
const nonMatchingResponse = await fetch(server.url, {
51+
method: 'POST',
52+
headers: { 'content-type': 'application/json' },
53+
body: JSON.stringify({
54+
jsonrpc: '2.0',
55+
id: '2',
56+
method: 'someOtherMethod',
57+
params: []
58+
})
59+
});
60+
61+
expect(matchingResponse.ok).to.equal(true);
62+
expect(nonMatchingResponse.ok).to.equal(false)
63+
expect(await nonMatchingResponse.text()).to.include(
64+
'No rules were found matching this request'
65+
);
66+
});
67+
68+
it("can match against specific JSON-RPC params", async () => {
69+
await server.forJsonRpcRequest({
70+
params: [{
71+
fieldA: 'value-to-match'
72+
}]
73+
}).thenSendJsonRpcResult({ value: 'mock-result' });
74+
75+
const matchingResponse = await fetch(server.url, {
76+
method: 'POST',
77+
headers: { 'content-type': 'application/json' },
78+
body: JSON.stringify({
79+
jsonrpc: '2.0',
80+
id: '1',
81+
method: 'getValue',
82+
params: [{
83+
fieldA: 'value-to-match',
84+
fieldB: 'another-ignored-field'
85+
}]
86+
})
87+
});
88+
89+
const nonMatchingResponse = await fetch(server.url, {
90+
method: 'POST',
91+
headers: { 'content-type': 'application/json' },
92+
body: JSON.stringify({
93+
jsonrpc: '2.0',
94+
id: '2',
95+
method: 'getValue',
96+
params: [] // No params at all
97+
})
98+
});
99+
100+
expect(matchingResponse.ok).to.equal(true);
101+
expect(nonMatchingResponse.ok).to.equal(false)
102+
expect(await nonMatchingResponse.text()).to.include(
103+
'No rules were found matching this request'
104+
);
105+
});
106+
107+
it("can match and mock a JSON-RPC error", async () => {
108+
await server.forJsonRpcRequest()
109+
.thenSendJsonRpcError({ code: 123, message: 'mock-error' });
110+
111+
const response = await fetch(server.url, {
112+
method: 'POST',
113+
headers: { 'content-type': 'application/json' },
114+
body: JSON.stringify({
115+
jsonrpc: '2.0',
116+
id: '1234',
117+
method: 'getValue',
118+
params: []
119+
})
120+
});
121+
122+
expect(response.ok).to.equal(true);
123+
expect(await response.json()).to.deep.equal({
124+
jsonrpc: '2.0',
125+
id: '1234',
126+
error: { code: 123, message: 'mock-error' }
127+
});
128+
});
129+
130+
it("does not match against non-JSON-RPC methods", async () => {
131+
await server.forJsonRpcRequest({
132+
method: 'getValue'
133+
}).thenSendJsonRpcResult({ value: 'mock-result' });
134+
135+
const response = await fetch(server.url, {
136+
method: 'POST',
137+
headers: { 'content-type': 'application/json' },
138+
body: 'hi there'
139+
});
140+
141+
expect(response.ok).to.equal(false);
142+
expect(await response.text()).to.include(
143+
'No rules were found matching this request'
144+
);
145+
});
146+
147+
it("should reject matched non-JSON-RPC requests explicitly", async () => {
148+
await server.forAnyRequest() // Matching anything, not just JSON-RPC
149+
.thenSendJsonRpcResult({ value: 'mock-result' });
150+
151+
const response = await fetch(server.url, {
152+
method: 'POST',
153+
headers: { 'content-type': 'application/json' },
154+
body: 'hi there'
155+
});
156+
157+
expect(response.ok).to.equal(false);
158+
expect(await response.text()).to.deep.equal(
159+
"Error: Can't send a JSON-RPC response to an invalid JSON-RPC request"
160+
);
161+
});
162+
163+
});

0 commit comments

Comments
 (0)