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 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
2,244 changes: 1,963 additions & 281 deletions yarn-project/.pnp.cjs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions yarn-project/eslint-config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ const contexts = [
'MethodDefinition',
'TSPropertySignature',
'PropertySignature',
'TSInterfaceDeclaration > TSPropertyDefinition',
'InterfaceDeclaration > PropertyDefinition',
'TSInterfaceDeclaration',
'InterfaceDeclaration',
'TSTypeAliasDeclaration',
'TypeAliasDeclaration',
// TODO how to ensure non-function exports are documented?
'TSTypeDeclaration',
'TypeDeclaration',
'TSEnumDeclaration',
Expand Down
1 change: 1 addition & 0 deletions yarn-project/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"eslint": "^8.21.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jsdoc": "^40.0.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-tsdoc": "^0.2.17"
}
}
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 createJsonRpcClient and JsonRpcServer call 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 = createJsonRpcClient<WalletImplementation>('wallet-server.com', /*register classes*/ {PublicKey, TxRequest});
const response = await wallet.signTxRequest(accountPubKey, txRequest);
```

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

In wallet:

```
const publicClient = createJsonRpcClient<PublicClient>('public-client.com', /*register classes*/ ...);
const keyStore = createJsonRpcClient<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);
```
49 changes: 49 additions & 0 deletions yarn-project/json-rpc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"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 16 ./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",
"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"
}
}
96 changes: 96 additions & 0 deletions yarn-project/json-rpc/src/class_converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { assert, hasOwnProperty } from './js_utils.js';

/**
* 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;
/**
* Creates an IOClass from a given string.
*/
fromString: (str: string) => any;
}

/**
* Registered classes available for conversion.
*/
export interface ClassConverterInput {
[className: string]: IOClass;
}

/**
* Represents a class in a JSON-friendly encoding.
*/
export interface JsonEncodedClass {
/**
* The class type.
*/
type: string;
/**
* The class data string.
*/
data: string;
}

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

/**
* Create a class converter from a table of classes.
* @param input - The class table.
*/
constructor(input: ClassConverterInput) {
for (const key of Object.keys(input)) {
this.register(key, input[key]);
}
}
/**
* Register a class with a certain name.
* This name is used for conversion from and to this class.
* @param type - The class name to use for serialization.
* @param class_ - The class object.
*/
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_);
}
/**
* Convert a JSON-like object to a class object.
* @param jsonObj - An object encoding a class.
* @returns The class object.
*/
toClassObj(jsonObj: JsonEncodedClass): any {
const class_ = this.toClass.get(jsonObj.type);
assert(class_, `Could not find type in lookup.`);
return class_!.fromString(jsonObj.data);
}
/**
* Convert a JSON-like object to a class object.
* @param classObj - A JSON encoding a class.
* @returns The class object.
*/
toJsonObj(classObj: any): JsonEncodedClass {
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 { createJsonRpcClient } from './json_rpc_client.js';
20 changes: 20 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,20 @@
import request from 'supertest';
import { JsonRpcServer } from '../server/json_rpc_server.js';
import { TestState, TestNote } from '../test/test_state.js';
import { createJsonRpcClient } from './json_rpc_client.js';

test('test an RPC function over client', async () => {
const mockFetch = async (host: string, 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 client = createJsonRpcClient<TestState>('', { TestNote }, mockFetch);
const result = await client.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');
});
86 changes: 86 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,86 @@
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';

/**
* A normal fetch function that does not retry.
* Alternatives are a fetch function with retries, or a mocked fetch.
* @param host - The host URL.
* @param method - The RPC method name.
* @param body - The RPC payload.
* @returns The parsed JSON response, or throws an error.
*/
export async function defaultFetch(host: string, rpcMethod: string, body: any) {
logTrace(`JsonRpcClient.fetch`, host, rpcMethod, '<-', body);
const resp = await fetch(`${host}/${rpcMethod}`, {
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}`);
}
}

/**
* A fetch function with retries.
*/
export async function mustSucceedFetch(host: string, rpcMethod: string, body: any) {
return await retry(() => defaultFetch(host, rpcMethod, body), 'JsonRpcClient request');
}

/**
* Creates a Proxy object that delegates over RPC and satisfies RemoteObject<T>.
* The server should have ran new JsonRpcServer().
*/
export function createJsonRpcClient<T extends object>(
host: string,
classMap: ClassConverterInput,
fetch = defaultFetch,
) {
const classConverter = new ClassConverter(classMap);
let id = 0;
const request = async (method: string, params: any[]): Promise<any> => {
const body = {
jsonrpc: '2.0',
id: id++,
method,
params: params.map(param => convertToJsonObj(classConverter, param)),
};
logTrace(`JsonRpcClient.request`, method, '<-', params);
const res = await fetch(host, method, body);
logTrace(`JsonRpcClient.request`, method, '->', res);
if (res.error) {
throw res.error;
}
return convertFromJsonObj(classConverter, res.result);
};

// Intercept any RPC methods with a proxy
// This wraps 'request' with a method-call syntax wrapper
return new Proxy(
{},
{
get:
(_, rpcMethod: string) =>
(...params: any[]) => {
logTrace(`JsonRpcClient.constructor`, 'proxy', rpcMethod, '<-', params);
return request(rpcMethod, params);
},
},
) as RemoteObject<T>;
}
15 changes: 15 additions & 0 deletions yarn-project/json-rpc/src/convert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ClassConverter } from './class_converter.js';
import { convertFromJsonObj, convertToJsonObj } from './convert.js';
import { TestNote } from './test/test_state.js';

import { Buffer } from 'buffer';

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');
});
Loading