diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcClient.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcClient.kt new file mode 100644 index 00000000..5a5acb35 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcClient.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import com.fasterxml.jackson.databind.ObjectMapper +import io.emeraldpay.dshackle.config.AuthConfig +import io.netty.buffer.Unpooled +import io.netty.handler.codec.http.HttpHeaderNames +import io.netty.handler.codec.http.HttpHeaders +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import reactor.netty.http.client.HttpClient +import java.util.* +import java.util.function.Consumer + +/** + * JSON RPC client + */ +class JsonRpcClient( + private val target: String, + private val objectMapper: ObjectMapper, + basicAuth: AuthConfig.ClientBasicAuth? +) { + + companion object { + private val log = LoggerFactory.getLogger(JsonRpcClient::class.java) + } + + private val parser = JsonRpcParser() + private val httpClient: HttpClient + + init { + var build = HttpClient.create() + + build = build.headers { h -> + h.add(HttpHeaderNames.CONTENT_TYPE, "application/json") + } + + basicAuth?.let { basicAuth -> + val authString: String = basicAuth.username + ":" + basicAuth.password + val authBase64 = Base64.getEncoder().encodeToString(authString.toByteArray()) + val auth = "Basic $authBase64" + val headers = Consumer { h: HttpHeaders -> h.add(HttpHeaderNames.AUTHORIZATION, auth) } + build = build.headers(headers) + } + + this.httpClient = build + } + + fun execute(request: JsonRpcRequest): Mono { + return Mono.just(request) + .map { it.toJson(objectMapper) } + .flatMap(this@JsonRpcClient::execute) + .map(parser::parse) + } + + fun execute(request: ByteArray): Mono { + val response = httpClient + .post() + .uri(target) + .send(Mono.just(request).map { Unpooled.wrappedBuffer(it) }) + + return response.responseContent() + .aggregate() + .asByteArray() + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcParser.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcParser.kt new file mode 100644 index 00000000..d7401a1d --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcParser.kt @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import io.infinitape.etherjar.rpc.RpcResponseError +import org.slf4j.LoggerFactory + +class JsonRpcParser() { + + companion object { + private val log = LoggerFactory.getLogger(JsonRpcParser::class.java) + } + + private val jsonFactory = JsonFactory() + + fun parse(json: ByteArray): JsonRpcResponse { + val parser: JsonParser = jsonFactory.createParser(json) + parser.nextToken() + if (parser.currentToken != JsonToken.START_OBJECT) { + println("token ${parser.currentToken}") + return JsonRpcResponse(null, JsonRpcResponse.ResponseError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON")) + } + var nullResponse: JsonRpcResponse? = null + while (parser.nextToken() != JsonToken.END_OBJECT) { + val field = parser.currentName + if (field == "jsonrpc" || field == "id") { + if (!parser.nextToken().isScalarValue) { + return JsonRpcResponse(null, JsonRpcResponse.ResponseError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON (id/type)")) + } + // just skip the field + } else if (field == "result") { + val value = parser.nextToken() + val start = parser.tokenLocation + if (value.isScalarValue) { + val text = parser.text + if (value == JsonToken.VALUE_STRING) { + return JsonRpcResponse(("\"" + text + "\"").toByteArray(), null) + } else if (value == JsonToken.VALUE_NULL) { + //if null we should check if error is present + nullResponse = JsonRpcResponse(text.toByteArray(), null) + } else { + return JsonRpcResponse(text.toByteArray(), null) + } + } else if (value == JsonToken.START_OBJECT || value == JsonToken.START_ARRAY) { + parser.skipChildren() + val end = parser.currentLocation.byteOffset.toInt() + val copy = ByteArray((end - start.byteOffset).toInt()) + System.arraycopy(json, start.byteOffset.toInt(), copy, 0, copy.size) + return JsonRpcResponse(copy, null) + } + } else if (field == "error") { + val err = readError(parser) + if (err != null) { + return JsonRpcResponse(null, err) + } + } + } + if (nullResponse != null) { + return nullResponse + } + return JsonRpcResponse(null, JsonRpcResponse.ResponseError(RpcResponseError.CODE_UPSTREAM_INVALID_RESPONSE, "Invalid JSON structure")) + } + + fun readError(parser: JsonParser): JsonRpcResponse.ResponseError? { + var code = 0 + var message = "" + + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() == JsonToken.VALUE_NULL) { + // error is just null + return null + } + val field = parser.currentName() + if (field == "code" && parser.currentToken == JsonToken.VALUE_NUMBER_INT) { + code = parser.intValue + } else if (field == "message" && parser.currentToken == JsonToken.VALUE_STRING) { + message = parser.valueAsString + } + } + + return JsonRpcResponse.ResponseError(code, message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt new file mode 100644 index 00000000..d8fd2d83 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import com.fasterxml.jackson.databind.ObjectMapper + +class JsonRpcRequest( + val method: String, + val params: List +) { + + fun toJson(objectMapper: ObjectMapper): ByteArray { + val json = mapOf( + "jsonrpc" to "2.0", + "id" to 1, + "method" to method, + "params" to params + ) + return objectMapper.writeValueAsBytes(json) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is JsonRpcRequest) return false + + if (method != other.method) return false + if (params != other.params) return false + + return true + } + + override fun hashCode(): Int { + var result = method.hashCode() + result = 31 * result + params.hashCode() + return result + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt new file mode 100644 index 00000000..b23eeed7 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider + +class JsonRpcResponse( + val result: ByteArray?, + val error: ResponseError? +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is JsonRpcResponse) return false + + if (result != null) { + if (other.result == null) return false + if (!result.contentEquals(other.result)) return false + } else if (other.result != null) return false + if (error != other.error) return false + + return true + } + + override fun hashCode(): Int { + var result1 = result?.contentHashCode() ?: 0 + result1 = 31 * result1 + (error?.hashCode() ?: 0) + return result1 + } + + class ResponseError(val code: Int, val message: String) + + class ResponseJsonSerializer : JsonSerializer() { + override fun serialize(value: JsonRpcResponse, gen: JsonGenerator, serializers: SerializerProvider) { + gen.writeStartObject() + gen.writeStringField("jsonrpc", "2.0") + gen.writeNumberField("id", 0) + if (value.error != null) { + gen.writeObjectFieldStart("error") + gen.writeNumberField("code", value.error.code) + gen.writeStringField("message", value.error.message) + gen.writeEndObject() + } else { + if (value.result == null) { + throw IllegalStateException("No result set") + } + gen.writeRawUTF8String(value.result, 0, value.result.size) + } + gen.writeEndObject() + } + } +} \ No newline at end of file diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcClientSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcClientSpec.groovy new file mode 100644 index 00000000..68a038e7 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcClientSpec.groovy @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import io.emeraldpay.dshackle.test.TestingCommons +import org.mockserver.integration.ClientAndServer +import org.mockserver.model.HttpRequest +import org.mockserver.model.HttpResponse +import spock.lang.Specification + +class JsonRpcClientSpec extends Specification { + + ClientAndServer mockServer + JsonRpcClient client + + def setup() { + mockServer = ClientAndServer.startClientAndServer(18332); + client = new JsonRpcClient("localhost:18332", TestingCommons.objectMapper(), null) + } + + def cleanup() { + mockServer.stop() + } + + + def "Make a request"() { + setup: + def resp = '{' + + ' "jsonrpc": "2.0",' + + ' "result": "0x98de45",' + + ' "error": null,' + + ' "id": 15' + + '}' + mockServer.when( + HttpRequest.request() + ).respond( + HttpResponse.response(resp) + ) + when: + def act = client.execute(new JsonRpcRequest("test", [])).block() + then: + act.error == null + new String(act.result) == '"0x98de45"' + } + +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcParserSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcParserSpec.groovy new file mode 100644 index 00000000..ebfc12e9 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcParserSpec.groovy @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import spock.lang.Specification + +class JsonRpcParserSpec extends Specification { + + JsonRpcParser parser = new JsonRpcParser() + + def "Parse just result"() { + setup: + def json = '{"result": "Hello world!"}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '"Hello world!"' + } + + def "Parse string response"() { + setup: + // 0 8 16 32 35 + def json = '{"jsonrpc": "2.0", "id": 1, "result": "Hello world!"}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '"Hello world!"' + } + + def "Parse string response when result starts first"() { + setup: + // 0 8 16 32 35 + def json = '{"result": "Hello world!", "jsonrpc": "2.0", "id": 1}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '"Hello world!"' + } + + def "Parse bool response"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": false}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == 'false' + } + + def "Parse bool response when id is last"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "result": false, "id": 1}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == 'false' + } + + def "Parse int response"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": 100}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '100' + } + + def "Parse null response"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": null}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == 'null' + } + + def "Parse object response"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": {"hash": "0x00000", "foo": false, "bar": 1}}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '{"hash": "0x00000", "foo": false, "bar": 1}' + } + + def "Parse object response with null error"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": {"hash": "0x00000", "foo": false, "bar": 1}, "error": null}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '{"hash": "0x00000", "foo": false, "bar": 1}' + } + + def "Parse object response if null error comes first"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "error": null, "result": {"hash": "0x00000", "foo": false, "bar": 1}}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '{"hash": "0x00000", "foo": false, "bar": 1}' + } + + def "Parse object response with extra spaces"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "result" : {"hash": "0x00000", "foo": false , "bar":1} , "id": 1}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '{"hash": "0x00000", "foo": false , "bar":1}' + } + + def "Parse complex object response"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": {"hash": "0x00000", "foo": {"bar": 1, "baz": 2}}}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '{"hash": "0x00000", "foo": {"bar": 1, "baz": 2}}' + } + + def "Parse array response"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": [1, 2, false]}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error == null + new String(act.result) == '[1, 2, false]' + } + + def "Parse error"() { + setup: + // 0 8 16 32 + def json = '{"jsonrpc": "2.0", "id": 1, "result": null, "error": {"code": -1111, "message": "test"}}' + when: + def act = parser.parse(json.getBytes()) + then: + act.error != null + act.error.code == -1111 + act.error.message == "test" + act.result == null + } + +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequestSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequestSpec.groovy new file mode 100644 index 00000000..0d135a4a --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequestSpec.groovy @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import io.emeraldpay.dshackle.test.TestingCommons +import spock.lang.Specification + +class JsonRpcRequestSpec extends Specification { + + def "Serialize empty params"() { + setup: + def req = new JsonRpcRequest("test_foo", []) + when: + def act = req.toJson(TestingCommons.objectMapper()) + then: + new String(act) == '{"jsonrpc":"2.0","id":1,"method":"test_foo","params":[]}' + } + + def "Serialize single param"() { + setup: + def req = new JsonRpcRequest("test_foo", ["0x0000"]) + when: + def act = req.toJson(TestingCommons.objectMapper()) + then: + new String(act) == '{"jsonrpc":"2.0","id":1,"method":"test_foo","params":["0x0000"]}' + } + + def "Serialize two params"() { + setup: + def req = new JsonRpcRequest("test_foo", ["0x0000", false]) + when: + def act = req.toJson(TestingCommons.objectMapper()) + then: + new String(act) == '{"jsonrpc":"2.0","id":1,"method":"test_foo","params":["0x0000",false]}' + } + + def "Same requests are equal"() { + setup: + def req1 = new JsonRpcRequest("test_foo", ["0x0000", false]) + def req2 = new JsonRpcRequest("test_foo", ["0x0000", false]) + when: + def act = req1.equals(req2) + then: + act == true + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy new file mode 100644 index 00000000..4f9ab808 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.rpcclient + +import spock.lang.Specification + +class JsonRpcResponseSpec extends Specification { + + def "Same responses are equal"() { + setup: + def resp1 = new JsonRpcResponse("\"hello\"".bytes, null) + def resp2 = new JsonRpcResponse("\"hello\"".bytes, null) + when: + def act = resp1.equals(resp2) + then: + act == true + } +}