-
-
Save dvv/4bf04c824678362b1c9c2c63fa3ab7b4 to your computer and use it in GitHub Desktop.
On a barebone jsonrpc2 implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { z } from "../../deps.ts" | |
// import * as Metrics from "./metrics.ts" | |
// https://www.jsonrpc.org/specification | |
const JsonRpcIdSchema = z.union([z.number().int(), z.string().min(1), z.null()]) | |
const JsonRpcBaseSchema = z.object({ | |
jsonrpc: z.literal("2.0"), | |
id: JsonRpcIdSchema, | |
}) | |
const JsonRpcCallSchema = z.object({ | |
method: z.string().refine(x => !x.startsWith("rpc."), { message: "Method name is reserved" }), | |
params: z.union([z.record(z.unknown()), z.array(z.unknown())]).optional(), | |
}) | |
type JsonRpcCallType = z.infer<typeof JsonRpcCallSchema> | |
const JsonRpcRequestSchema = JsonRpcBaseSchema.merge(JsonRpcCallSchema).extend({ | |
id: JsonRpcIdSchema.optional(), | |
}) | |
type JsonRpcRequestType = z.infer<typeof JsonRpcRequestSchema> | |
const JsonRpcSuccessSchema = z.object({ | |
result: z.unknown(), | |
}) | |
type JsonRpcSuccessType = z.infer<typeof JsonRpcSuccessSchema> | |
const JsonRpcFailureSchema = z.object({ | |
error: z.object({ | |
code: z.number().int(), | |
message: z.string().min(1), | |
data: z.unknown().optional(), | |
}), | |
}) | |
type JsonRpcFailureType = z.infer<typeof JsonRpcFailureSchema> | |
const JsonRpcResponseSuccessSchema = JsonRpcBaseSchema.merge(JsonRpcSuccessSchema) | |
const JsonRpcResponseErrorSchema = JsonRpcBaseSchema.merge(JsonRpcFailureSchema) | |
const JsonRpcResponseSchema = z.union([JsonRpcResponseErrorSchema, JsonRpcResponseSuccessSchema]) // NB: order matters | |
type JsonRpcResponseType = z.infer<typeof JsonRpcResponseSchema> | |
interface JsonRpcContext { | |
meta: { | |
method?: string | |
args?: unknown | |
result?: unknown | |
duration_ms?: number | |
error?: "method_not_found" | "invalid_arguments" | "invalid_return_type" | "exception" | unknown | |
stack?: unknown | |
} | |
} | |
interface JsonRpcHandler { | |
in?: z.ZodSchema | |
out?: z.ZodSchema | |
// deno-lint-ignore ban-types | |
fn: Function | |
} | |
export class JsonRpc<Context extends JsonRpcContext> { | |
public constructor(private readonly rpcMethods: { [method: string]: JsonRpcHandler }) { | |
} | |
async handleJson(json: string, ctx: Context): Promise<string | undefined> { | |
let data | |
try { | |
data = this.#parse(json) | |
} catch { | |
return this.#stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }) | |
} | |
data = await this.handleRequest(data, ctx) | |
data = data === undefined ? undefined : this.#stringify(data) | |
return data | |
} | |
// protected authorize(_ctx: Context) { | |
// return true | |
// } | |
protected log(_ctx: Context) { | |
// NB: _ctx.meta contains useful metadata | |
} | |
async handleRequest(input: unknown, ctx: Context): Promise<JsonRpcResponseType | JsonRpcResponseType[] | undefined> { | |
const isBatch = Array.isArray(input) | |
if (isBatch && input.length < 1) { | |
return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid Request" }, id: null } | |
} | |
const responses = (await Promise.all((isBatch ? input : [input]).map(async input => { | |
const c = Object.create(ctx, { meta: { value: { ...ctx.meta } } }) | |
const ri = JsonRpcRequestSchema.safeParse(input) | |
c.meta.args = input | |
if (!ri.success) { | |
return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid Request" }, id: null } | |
} | |
c.meta.args = ri.data.params | |
c.meta.method = ri.data.method | |
if (!(ri.data.method in this.rpcMethods)) { | |
ctx.meta.error = "method_not_found" | |
return { jsonrpc: "2.0", error: { code: -32601, message: "Method not found" }, id: ri.data.id! } | |
} | |
const output = await this.handleMethod(ri.data, c) | |
this.log(c) | |
return { jsonrpc: "2.0", ...output, id: ri.data.id! } | |
}))).filter(response => response.id !== undefined) as JsonRpcResponseType[] | |
if (responses.length === 0) return | |
if (isBatch) return responses | |
return responses[0] | |
} | |
async handleMethod(request: JsonRpcCallType, ctx: Context): Promise<JsonRpcSuccessType | JsonRpcFailureType> { | |
const handler = this.rpcMethods[request.method] | |
ctx.meta.args = request.params | |
if (handler.in) { | |
const ri = handler.in.safeParse(request.params) | |
if (!ri.success) { | |
ctx.meta.error = "invalid_arguments" | |
const errors = ri.error.issues.map(({ path, message }) => `${path.join(".") || "$"}: ${message}`) | |
return { error: { code: -32602, message: "Invalid params", data: errors } } | |
} | |
ctx.meta.args = ri.data | |
} | |
// if (!this.authorize(ctx)) { | |
// return { error: { code: -32001, message: "Unauthorized" } } | |
// } | |
// Metrics.rpcRequestsInFlight.inc() | |
const start_time = Date.now() | |
let result | |
try { | |
result = await handler.fn.apply(ctx, Array.isArray(request.params) ? [...request.params, ctx] : [request.params, ctx]) | |
ctx.meta.result = result | |
} catch (e) { | |
if (e instanceof z.ZodError) { | |
ctx.meta.error = "validation" | |
} else { | |
ctx.meta.error = "exception" | |
} | |
ctx.meta.stack = e.stack | |
return { error: { code: -32603, message: "Internal error" } } | |
} finally { | |
ctx.meta.duration_ms = Date.now() - start_time | |
// Metrics.rpcRequestsDuration.labels({ | |
// method: ctx.meta.method!, | |
// status: ctx.meta.error as string ?? "ok", | |
// }).observe(ctx.meta.duration_ms) | |
// Metrics.rpcRequestsTotal.labels({ | |
// method: ctx.meta.method!, | |
// status: ctx.meta.error as string ?? "ok", | |
// }).inc() | |
// Metrics.rpcRequestsInFlight.dec() | |
} | |
if (handler.out) { | |
const ro = handler.out.safeParse(result) | |
if (!ro.success) { | |
ctx.meta.error = "invalid_return_type" | |
ctx.meta.stack = ro.error.stack | |
return { error: { code: -32603, message: "Invalid result" } } | |
} | |
ctx.meta.result = result = ro.data | |
} | |
return { result } | |
} | |
#parse(json: string) { | |
return JSON.parse(json) | |
} | |
#stringify(obj: unknown) { | |
return JSON.stringify(obj) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment