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

feat(json-rpc): initial package #2

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 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
1,619 changes: 1,618 additions & 1 deletion yarn-project/.pnp.cjs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions yarn-project/json-rpc/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
require('@rushstack/eslint-patch/modern-module-resolution');

module.exports = {
extends: ['@aztec/eslint-config'],
parserOptions: { tsconfigRootDir: __dirname },
};
7 changes: 7 additions & 0 deletions yarn-project/json-rpc/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM 278380418400.dkr.ecr.eu-west-2.amazonaws.com/yarn-project-base AS builder
COPY json-rpc json-rpc
WORKDIR /usr/src/yarn-project/json-rpc
RUN yarn build && yarn formatting && yarn test

FROM alpine:latest
COPY --from=builder /usr/src/yarn-project/json-rpc /usr/src/yarn-project/json-rpc
55 changes: 55 additions & 0 deletions yarn-project/json-rpc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# json-rpc

json-rpc

```
-- src
-- client
Code to use by a client wishing to use a json-rpc server
Includes syntax sugar for making requests with normal method syntax
-- server
Code for easily turning a class into an exposed RPC with one endpoint per method
```

Each JsonRpcClient and JsonRpcServer class needs a map of classes that will be translated in input and output values.
By default, Buffer is handled, but other usermade classes need to define toString() and static fromString() like so:

```
class PublicKey {
toString() {
return '...';
}
static fromString(str) {
return new PublicKey(...);
}
}
```

## Usage

In Dapp:

```
const wallet = new JsonRpcClient<WalletImplementation>('wallet-server.com', /*register classes*/ {PublicKey, TxRequest});
const response = await wallet.rpc.signTxRequest(accountPubKey, txRequest);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make it call the methods directly from the client: wallet.signTxRequest?

Copy link
Collaborator Author

@ludamad ludamad Mar 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorta. You need a proxy object to do this, I don't think you can do new ... and have something end up a proxy object. If we instead had a createRpcClient function we could do it. It could just return the .rpc object

```

The client will send `[{ name: 'PublicKey', value: accountPubKey.toString() }, { name: 'TxRequest', txRequest.toString() }]` to the server.

In wallet:

```
const publicClient = new JsonRpcClient<PublicClient>('public-client.com', /*register classes*/ ...);
const keyStore = new JsonRpcClient<KeyStore>('key-store.com', /*register classes*/ ...);
```

Different clients for different services.

-- server
Running a wallet server:

```
const wallet = new WalletImplementation();
const server = new JsonRpcServer(wallet, /*register classes*/ ...);
server.start(8080);
```
54 changes: 54 additions & 0 deletions yarn-project/json-rpc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@aztec/json-rpc",
"version": "0.0.0",
"type": "module",
"exports": "./dest/index.js",
"scripts": {
"build": "yarn clean && yarn formatting && tsc -b tsconfig.dest.json",
"build:dev": "tsc -b tsconfig.dest.json --watch",
"clean": "rm -rf ./dest .tsbuildinfo",
"formatting": "run -T prettier --check ./src && run -T eslint --max-warnings 0 ./src",
"test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules $(yarn bin jest) --no-cache",
"test-debug": "NODE_NO_WARNINGS=1 node --inspect-brk --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --testTimeout 1000000"
},
"jest": {
"preset": "ts-jest/presets/default-esm",
"globals": {
"ts-jest": {
"useESM": true
}
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"testRegex": "./src/.*\\.test\\.ts$",
"rootDir": "./src"
},
"dependencies": {
"@koa/cors": "^4.0.0",
"koa": "^2.14.1",
"koa-bodyparser": "^4.3.0",
"koa-compress": "^5.1.0",
"koa-router": "^12.0.0",
"tslib": "^2.4.0"
},
"devDependencies": {
"@aztec/eslint-config": "workspace:^",
"@jest/globals": "^29.4.3",
"@rushstack/eslint-patch": "^1.1.4",
"@types/jest": "^29.4.0",
"@types/koa": "^2.13.5",
"@types/koa-bodyparser": "^4.3.10",
"@types/koa-compress": "^4.0.3",
"@types/koa-router": "^7.0.42",
"@types/koa__cors": "^3.3.0",
"@types/node": "^18.7.23",
"@types/supertest": "^2.0.12",
"comlink": "^4.4.1",
"jest": "^29.4.3",
"supertest": "^6.3.3",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
}
}
55 changes: 55 additions & 0 deletions yarn-project/json-rpc/src/class_converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { assert, hasOwnProperty } from './js_utils.js';

/**
* IOClass:
* Represents a class compatible with our class conversion system
* e.g. PublicKey here satisfies 'IOClass'
* class PublicKey {
* toString() {
* return '...';
* }
* static fromString(str) {
* return new PublicKey(...);
* }
* }
*/
interface IOClass {
new (...args: any): any;
fromString: (str: string) => any;
}

export interface ClassConverterInput {
[className: string]: IOClass;
}

/**
* Handles mapping of classes to names, and calling toString and fromString
* to convert to and from JSON-friendly formats
*/
export class ClassConverter {
private toClass = new Map<string, IOClass>();
private toName = new Map<IOClass, string>();

constructor(input: ClassConverterInput) {
for (const key of Object.keys(input)) {
this.register(key, input[key]);
}
}
register(type: string, class_: IOClass) {
assert(type !== 'Buffer', "'Buffer' handling is hardcoded. Cannot use as name.");
assert(hasOwnProperty(class_.prototype, 'toString'), `Class ${type} must define a toString() method.`);
assert(class_['fromString'], `Class ${type} must define a fromString() static method.`);
this.toName.set(class_, type);
this.toClass.set(type, class_);
}
toClassObj(jsonObj: { type: string; data: string }) {
const class_ = this.toClass.get(jsonObj.type);
assert(class_, `Could not find type in lookup.`);
return class_!.fromString(jsonObj.data);
}
toJsonObj(classObj: any) {
const type = this.toName.get(classObj.constructor);
assert(type, `Could not find class in lookup.`);
return { type: type!, data: classObj.toString() };
}
}
1 change: 1 addition & 0 deletions yarn-project/json-rpc/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { JsonRpcClient } from './json_rpc_client.js';
21 changes: 21 additions & 0 deletions yarn-project/json-rpc/src/client/json_rpc_client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import request from 'supertest';
import { JsonRpcServer } from '../server/json_rpc_server.js';
import { TestState, TestNote } from '../test/test_state.js';
import { JsonRpcClient } from './json_rpc_client.js';

test('test an RPC function over client', async () => {
const client = new JsonRpcClient<TestState>('', { TestNote });
// Mock the method
(client as any).fetch = async (method: string, body: any) => {
const server = new JsonRpcServer(new TestState([new TestNote('a'), new TestNote('b')]), { TestNote });
const result = await request(server.getApp().callback()).post(`/${method}`).send(body);
return JSON.parse(result.text);
};
const result = await client.rpc.addNotes([new TestNote('c')]);
expect(result[0]).toBeInstanceOf(TestNote);
expect(result[1]).toBeInstanceOf(TestNote);
expect(result[2]).toBeInstanceOf(TestNote);
expect(result[0].toString()).toBe('a');
expect(result[1].toString()).toBe('b');
expect(result[2].toString()).toBe('c');
});
82 changes: 82 additions & 0 deletions yarn-project/json-rpc/src/client/json_rpc_client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { retry } from '../js_utils.js';
import { logTrace } from '../log_utils.js';
// comlink:
// Dev dependency just for the somewhat complex RemoteObject type
// This takes a {foo(): T} and makes {foo(): Promise<T>}
// while avoiding Promise of Promise.
import { RemoteObject } from 'comlink';
import { ClassConverter, ClassConverterInput } from '../class_converter.js';
import { convertFromJsonObj, convertToJsonObj } from '../convert.js';

export class JsonRpcClient<T extends object> {
private classConverter: ClassConverter;
public readonly rpc: RemoteObject<T>;
private id = 0;

constructor(private host: string, classMap: ClassConverterInput, private netMustSucceed = false) {
// the rpc object has syntax sugar over 'request'
// for accessing the remote methods
this.rpc = this.createRpcProxy();
this.classConverter = new ClassConverter(classMap);
}
/**
* Creates a Proxy object that delegates over RPC
* and satisfies RemoteObject<T>
*/
private createRpcProxy(): RemoteObject<T> {
return new Proxy(
{},
{
get:
(_, method: string) =>
(...params: any[]) => {
logTrace(`JsonRpcClient.constructor`, 'proxy', method, '<-', params);
return this.request(method, params);
},
},
) as RemoteObject<T>;
}
public async request(method: string, params: any[]): Promise<any> {
const body = {
jsonrpc: '2.0',
id: this.id++,
method,
params: params.map(param => convertToJsonObj(this.classConverter, param)),
};
logTrace(`JsonRpcClient.request`, method, '<-', params);
const res = await this.fetch(method, body);
logTrace(`JsonRpcClient.request`, method, '->', res);
if (res.error) {
throw res.error;
}
return convertFromJsonObj(this.classConverter, res.result);
}

private async fetch(method: string, body: any) {
const tryOnce = async () => {
logTrace(`JsonRpcClient.fetch`, this.host, method, '<-', body);
const resp = await fetch(`${this.host}/${method}`, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
});

if (!resp.ok) {
throw new Error(resp.statusText);
}

const text = await resp.text();
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`Failed to parse body as JSON: ${text}`);
}
};

if (this.netMustSucceed) {
return await retry(tryOnce, 'JsonRpcClient request');
}

return await tryOnce();
}
}
13 changes: 13 additions & 0 deletions yarn-project/json-rpc/src/convert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ClassConverter } from './class_converter.js';
import { convertFromJsonObj, convertToJsonObj } from './convert.js';
import { TestNote } from './test/test_state.js';

const TEST_BASE64 = 'YmFzZTY0IGRlY29kZXI=';
test('test an RPC function over client', () => {
const cc = new ClassConverter({ TestNote });
const buffer = Buffer.from(TEST_BASE64, 'base64');
expect(convertFromJsonObj(cc, convertToJsonObj(cc, buffer)).toString('base64')).toBe(TEST_BASE64);
const note = new TestNote('1');
expect(convertFromJsonObj(cc, convertToJsonObj(cc, note))).toBeInstanceOf(TestNote);
expect(convertFromJsonObj(cc, convertToJsonObj(cc, note)).toString()).toBe('1');
});
60 changes: 60 additions & 0 deletions yarn-project/json-rpc/src/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ClassConverter } from './class_converter.js';

export function convertFromJsonObj(cc: ClassConverter, obj: any): any {
if (!obj) {
return obj; // Primitive type
}
// Is this a serialized Node buffer?
if (obj.type === 'Buffer' && typeof obj.data === 'string') {
return Buffer.from(obj.data, 'base64');
}
// Is this a convertible type?
if (typeof obj.type === 'string' && typeof obj.data === 'string') {
return cc.toClassObj(obj);
}

// Is this an array?
if (Array.isArray(obj)) {
return obj.map((x: any) => convertFromJsonObj(cc, x));
}
// Is this a dictionary?
if (obj.constructor === Object) {
const newObj: any = {};
for (const key of Object.keys(obj)) {
newObj[key] = convertFromJsonObj(cc, obj[key]);
}
return newObj;
}

// Leave alone, assume JSON primitive
return obj;
}

export function convertToJsonObj(cc: ClassConverter, obj: any): any {
if (!obj) {
return obj; // Primitive type
}
// Is this a Node buffer?
if (obj instanceof Buffer) {
return { type: 'Buffer', data: obj.toString('base64') };
}
// Is this a convertible type?
if (obj.constructor.fromString) {
return cc.toJsonObj(obj);
}
// Is this an array?
if (Array.isArray(obj)) {
return obj.map((x: any) => convertToJsonObj(cc, x));
}
// Is this a dictionary?
if (obj.constructor === Object) {
const newObj: any = {};
for (const key of Object.keys(obj)) {
newObj[key] = convertToJsonObj(cc, obj[key]);
}
return newObj;
}

// Leave alone, assume JSON primitive
return obj;
}
Loading