Skip to content

Commit 1153c28

Browse files
committed
Add JSON-RPC exchange matching & parsing
1 parent 9314b7b commit 1153c28

File tree

5 files changed

+227
-3
lines changed

5 files changed

+227
-3
lines changed

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@fortawesome/react-fontawesome": "^0.1.8",
3838
"@httptoolkit/auth0-lock": "^11.26.3",
3939
"@httptoolkit/httpsnippet": "^2.1.1",
40+
"@open-rpc/meta-schema": "^1.14.2",
4041
"@reach/router": "^1.2.1",
4142
"@sentry/browser": "^4.2.4",
4243
"@sentry/webpack-plugin": "^1.14.0",

src/model/api/api-interfaces.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export interface ApiParameter {
6060
| 'cookie'
6161
| 'path'
6262
| 'header'
63-
| 'query';
63+
| 'query'
64+
| 'body';
6465
required: boolean;
6566
deprecated: boolean;
6667
warnings: string[];

src/model/api/jsonrpc.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import {
2+
ContentDescriptorObject,
3+
JSONSchemaObject,
4+
MethodObject,
5+
OpenrpcDocument
6+
} from "@open-rpc/meta-schema";
7+
import { SchemaObject } from "openapi-directory";
8+
import { HtkResponse, Html, HttpExchange } from "../../types";
9+
import { ErrorLike, isErrorLike } from "../../util/error";
10+
import { fromMarkdown } from "../markdown";
11+
import {
12+
ApiExchange,
13+
ApiOperation,
14+
ApiParameter,
15+
ApiRequest,
16+
ApiResponse,
17+
ApiService
18+
} from "./api-interfaces";
19+
20+
export type OpenRpcDocument = OpenrpcDocument;
21+
22+
export interface OpenRpcMetadata {
23+
type: 'openrpc';
24+
spec: OpenRpcDocument;
25+
serverMatcher: RegExp;
26+
requestMatchers: { [methodName: string] : MethodObject }; // JSON-RPC method name to method
27+
}
28+
29+
export async function parseRpcApiExchange(
30+
api: OpenRpcMetadata,
31+
exchange: HttpExchange
32+
): Promise<JsonRpcApiExchange> {
33+
try {
34+
const body = await exchange.request.body.decodedPromise;
35+
36+
if (!body?.length) throw new Error(`No JSON-RPC request body`);
37+
38+
let parsedBody: any;
39+
let methodName: string;
40+
try {
41+
parsedBody = JSON.parse(body?.toString());
42+
if (parsedBody.jsonrpc !== '2.0') throw new Error(
43+
`JSON-RPC request body had bad 'jsonrpc' field: ${parsedBody.jsonrpc}`
44+
);
45+
46+
methodName = parsedBody.method;
47+
} catch (e) {
48+
console.warn(e);
49+
throw new Error('Could not parse JSON-RPC request body');
50+
}
51+
52+
const methodSpec = api.requestMatchers[methodName];
53+
if (!methodSpec) throw new Error(`Unrecognized JSON-RPC method name: ${methodName}`);
54+
55+
const operation = {
56+
methodSpec,
57+
parsedBody
58+
};
59+
60+
return new JsonRpcApiExchange(api, exchange, operation);
61+
} catch (error) {
62+
return new JsonRpcApiExchange(api, exchange, error as ErrorLike);
63+
}
64+
}
65+
66+
interface MatchedOperation {
67+
methodSpec: MethodObject;
68+
parsedBody: any;
69+
}
70+
71+
export class JsonRpcApiExchange implements ApiExchange {
72+
73+
constructor(
74+
private _api: OpenRpcMetadata,
75+
private _exchange: HttpExchange,
76+
private _rpcMethod: MatchedOperation | ErrorLike
77+
) {
78+
this.service = new JsonRpcApiService(_api);
79+
80+
if (isErrorLike(_rpcMethod)) {
81+
this.operation = {
82+
name: 'Unrecognized request to JSON-RPC API',
83+
warnings: [_rpcMethod.message ?? _rpcMethod.toString()]
84+
};
85+
this.request = { parameters: [] };
86+
} else {
87+
this.operation = new JsonRpcApiOperation(
88+
_rpcMethod,
89+
_api.spec.externalDocs?.['x-method-base-url'] // Custom extension
90+
);
91+
this.request = new JsonRpcApiRequest(_rpcMethod, _exchange);
92+
}
93+
}
94+
95+
readonly service: ApiService;
96+
readonly operation: ApiOperation;
97+
readonly request: ApiRequest;
98+
response: ApiResponse | undefined;
99+
100+
updateWithResponse(response: HtkResponse | "aborted" | undefined): void {
101+
if (
102+
response === 'aborted' ||
103+
response === undefined ||
104+
isErrorLike(this._rpcMethod)
105+
) return;
106+
107+
this.response = new JsonRpcApiResponse(this._rpcMethod);
108+
}
109+
110+
matchedOperation(): boolean {
111+
return !!this._rpcMethod;
112+
}
113+
114+
}
115+
116+
export class JsonRpcApiService implements ApiService {
117+
118+
constructor(api: OpenRpcMetadata) {
119+
this.name = api.spec.info.title;
120+
this.logoUrl = api.spec.info['x-logo']?.url;
121+
this.description = fromMarkdown(api.spec.info.description);
122+
this.docsUrl = api.spec.externalDocs?.url;
123+
}
124+
125+
readonly name: string;
126+
readonly logoUrl?: string | undefined;
127+
readonly description?: Html | undefined;
128+
readonly docsUrl?: string | undefined;
129+
130+
}
131+
132+
export class JsonRpcApiOperation implements ApiOperation {
133+
134+
constructor(
135+
rpcMethod: MatchedOperation,
136+
methodDocsBaseUrl: string | undefined
137+
) {
138+
const { methodSpec } = rpcMethod;
139+
140+
this.name = methodSpec.name;
141+
this.description = fromMarkdown([
142+
methodSpec.summary,
143+
methodSpec.description
144+
].filter(x => !!x).join('\n\n'));
145+
this.docsUrl = methodSpec.externalDocs?.url
146+
?? (methodDocsBaseUrl
147+
? methodDocsBaseUrl + methodSpec.name.toLowerCase()
148+
: undefined
149+
);
150+
151+
if (methodSpec.deprecated) {
152+
this.warnings.push(`The '${this.name}' method is deprecated.`);
153+
}
154+
}
155+
156+
name: string;
157+
description?: Html | undefined;
158+
docsUrl?: string | undefined;
159+
warnings: string[] = [];
160+
161+
}
162+
163+
export class JsonRpcApiRequest implements ApiRequest {
164+
165+
constructor(rpcMethod: MatchedOperation, exchange: HttpExchange) {
166+
const { methodSpec, parsedBody } = rpcMethod;
167+
168+
this.parameters = (methodSpec.params as ContentDescriptorObject[])
169+
.map((param: ContentDescriptorObject, i: number) => ({
170+
name: param.name,
171+
description: fromMarkdown([
172+
param.summary,
173+
param.description,
174+
(param.schema as JSONSchemaObject)?.title
175+
].filter(x => !!x).join('\n\n')),
176+
in: 'body',
177+
required: !!param.required,
178+
deprecated: !!param.deprecated,
179+
value: parsedBody.params[i],
180+
defaultValue: (param.schema as JSONSchemaObject).default,
181+
warnings: [
182+
...(param.deprecated ? [`The '${param.name}' parameter is deprecated.`] : []),
183+
...(param.required &&
184+
parsedBody.params[i] === undefined &&
185+
(param.schema as JSONSchemaObject).default === undefined
186+
? [`The '${param.name}' parameter is required.`]
187+
: []
188+
)
189+
]
190+
}));
191+
}
192+
193+
parameters: ApiParameter[];
194+
195+
}
196+
197+
export class JsonRpcApiResponse implements ApiResponse {
198+
199+
constructor(rpcMethod: MatchedOperation) {
200+
const resultSpec = rpcMethod.methodSpec.result as ContentDescriptorObject;
201+
202+
this.description = fromMarkdown(resultSpec.description);
203+
this.bodySchema = resultSpec.schema as SchemaObject;
204+
}
205+
206+
description?: Html;
207+
bodySchema?: SchemaObject;
208+
209+
}

src/util/error.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ export function asError(error: any): Error {
2626

2727
export class UnreachableCheck extends Error {
2828

29-
constructor(value: never) {
30-
super(`Unhandled switch value: ${value}`);
29+
// getValue is used to allow logging properties (e.g. v.type) on expected-unreachable
30+
// values, instead of just logging [object Object].
31+
constructor(value: never, getValue: (v: any) => any = (x => x)) {
32+
super(`Unhandled switch value: ${getValue(value)}`);
3133
}
3234

3335
}

0 commit comments

Comments
 (0)