solution: basic implementation for Proxy endpoint

This commit is contained in:
Igor Artamonov
2020-03-19 23:34:32 -04:00
parent 7455949541
commit 26f8dd294e
27 changed files with 1309 additions and 135 deletions

View File

@@ -24,18 +24,18 @@ The main goals of the project is to:
The main features and advantages are: The main features and advantages are:
- leveraging gRPC and HTTP2 protocols, with server push and asynchronous communications, to simplify and optimize standard - provides standard JSON RPC as smart load balancing proxy to active nodes
access patterns - in addition to JSON RPC it provides gRPC protocol, with server push and asynchronous communications, to simplify and optimize standard access patterns
- targeting Kubernetes architecture - targeting Kubernetes architecture
- automatically distributing access to API through multiple different target nodes, taking into account their current - automatically distributing access to API through multiple different target nodes, taking into account their current availability and status
availability and status - allowing to build a mesh network of routers in different regions sharing a set of underlying nodes, with automatic rebalancing and smart routing
- allowing to build a mesh network of routers in different regions sharing a set of underlying nodes, with automatic
rebalancing and smart routing
- caching data on the edge - caching data on the edge
- providing monitoring (ex. Prometheus) and externalizable logging - providing monitoring (ex.
Prometheus) and externalizable logging
- configurable access authentication and authorization, including TLS certificates - configurable access authentication and authorization, including TLS certificates
Dshackle connects to several upstreams via JSON RPC, Websockets, or gRPC protocols. It verifies if a node ("upstream") is Dshackle connects to several upstreams via JSON RPC, Websockets, or gRPC protocols.
It verifies if a node ("upstream") is
fully synchronized (not in initial sync mode), has enough peers, and its height is not behind other nodes. If upstream lags fully synchronized (not in initial sync mode), has enough peers, and its height is not behind other nodes. If upstream lags
behind others, lost peers, started to resync or went down, then Dshackle temporarily excludes it from requests and returns behind others, lost peers, started to resync or went down, then Dshackle temporarily excludes it from requests and returns
when the upstream problem is fixed. when the upstream problem is fixed.
@@ -44,14 +44,14 @@ image::call-schema.png[alt="Call Schema",width=100%,align="center"]
== Roadmap == Roadmap
- [ ] JSON RPC emulation, in addition to gRPC protocol - [x] JSON RPC emulation, in addition to gRPC protocol
- [ ] *Support Bitcoin RPC* - [ ] *Support Bitcoin RPC*
- [ ] External logging
- [ ] Access to ERC-20 tokens on asset level - [ ] Access to ERC-20 tokens on asset level
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ) - [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
- [ ] Prometheus monitoring - [ ] Prometheus monitoring
- [ ] BIP-32 Pubkey - [ ] BIP-32 Pubkey
- [ ] Lightweight sidecar node connector - [ ] Lightweight sidecar node connector
- [ ] External logging
- [ ] Configurable upstream roles - [ ] Configurable upstream roles
== Quick Start == Quick Start
@@ -59,23 +59,39 @@ image::call-schema.png[alt="Call Schema",width=100%,align="center"]
=== Configuration === Configuration
Create file `dshackle.yaml` with following content: Create file `dshackle.yaml` with following content:
[source,yaml] [source,yaml]
---- ----
version: v1 version: v1
port: 2449 port: 2449
tls: tls:
enabled: false enabled: false
proxy:
host: 0.0.0.0
port: 8545
routes:
- id: eth
blockchain: ethereum
- id: kovan
blockchain: kovan
upstreams: upstreams:
config: "upstreams.yaml" config: "upstreams.yaml"
---- ----
Which sets the following: Which sets the following:
- application listen on 0.0.0.0:2449 - gRPC access through 0.0.0.0:2449
- TLS security is disabled (_don't use in production!_) ** TLS security is disabled (_don't use in production!_)
- JSON RPC access through 0.0.0.0:8545
** proxying requests to Ethereum and Kovan upstreams
** request path for Ethereum Mainnet is `/eth`, for Kovan is `/kovan`
** i.e. call Mainnet by `POST http://127.0.0.0:8545/eth` with JSON RPC payload
- read upstreams configuration from file `upstreams.yaml` in the current directory - read upstreams configuration from file `upstreams.yaml` in the current directory
Now create file `upstreams.yaml`: Now create file `upstreams.yaml`:
[source,yaml] [source,yaml]
---- ----
version: v1 version: v1
@@ -116,12 +132,32 @@ export INFURA_USER=...
.Run Dshackle .Run Dshackle
[source,bash] [source,bash]
---- ----
docker run -p 2449:2449 -v $(pwd):/etc/dshackle -e "INFURA_USER=$INFURA_USER" emeraldpay/dshackle docker run -p 2449:2449 -p 8545:8545 -v $(pwd):/etc/dshackle -e "INFURA_USER=$INFURA_USER" emeraldpay/dshackle
---- ----
Now it listen on port 2449 at the localhost and can be connected from any gRPC compatible client. Now it listen on port 2449 at the localhost and can be connected from any gRPC compatible client.
Tools such as https://github.com/fullstorydev/grpcurl[gRPCurl] can automatically parse protobuf definitions and connect Tools such as https://github.com/fullstorydev/grpcurl[gRPCurl] can automatically parse protobuf definitions and connect to it (actual Protobuf sources are located in a separate repository which you can find at https://github.com/emeraldpay/proto)
to it (actual Protobuf sources are located in a separate repository which you can find at https://github.com/emeraldpay/proto)
==== Access using JSON RPC
Dshackle implements standard JSON RPC interface, providing additional caching layer, upstream readiness/liveness checks, retry and other features for building Fault Tolerant services.
.Request using Curl
[source,bash]
----
curl --request POST \
--url http://localhost:8545/eth \
--header 'content-type: application/json' \
--data '{"jsonrpc":"2.0", "method":"eth_getBalance", "id":1, "params":["0x690b2bdf41f33f9f251ae0459e5898b856ed96be", "latest"]}'
----
.Output
[source,bash]
----
{"jsonrpc":"2.0","id":1,"result":"0x72fa5e0181"}
----
==== Access using gRPC
.Connect and listen for new blocks on Ethereum Mainnet .Connect and listen for new blocks on Ethereum Mainnet
[source,bash] [source,bash]
@@ -149,7 +185,7 @@ grpcurl -import-path ./proto/ -proto blockchain.proto -d "{\"type\": 100}" -plai
... ...
---- ----
The output above is for a _streaming subscription_ to all new blocks on Ethereum Mainnet. It's one of services provided The output above is for a _streaming subscription_ to all new blocks on Ethereum Mainnet.It's one of services provided
by Dshackle, in additional to standard methods provided by RPC JSON of underlying nodes. by Dshackle, in additional to standard methods provided by RPC JSON of underlying nodes.
== Documentation == Documentation
@@ -158,12 +194,17 @@ For detailed documentation see link:docs/[] directory.
== Client Libraries == Client Libraries
Dshackle should be compatible with all standard libraries that use Ethereum JSON RPC.
But in addition to JSON RPC it provides gRPC API with many additional features and asynchronous access (please refer to the documentation: link:docs/06-methods.adoc[gRPC Methods]).
Below is the list of the libraries to use native gRPC API.
=== Java gRPC Client === Java gRPC Client
image:https://api.bintray.com/packages/emerald/emerald-grpc/emerald-grpc/images/download.svg[link="https://bintray.com/emerald/emerald-grpc/emerald-grpc/"] image:https://api.bintray.com/packages/emerald/emerald-grpc/emerald-grpc/images/download.svg[link="https://bintray.com/emerald/emerald-grpc/emerald-grpc/"]
https://github.com/emeraldpay/emerald-java-client https://github.com/emeraldpay/emerald-java-client
[source,groovy] [source,groovy]
---- ----
repositories { repositories {

View File

@@ -56,8 +56,8 @@ dependencies {
compile "io.grpc:grpc-protobuf:${grpcVersion}" compile "io.grpc:grpc-protobuf:${grpcVersion}"
compile "io.grpc:grpc-stub:${grpcVersion}" compile "io.grpc:grpc-stub:${grpcVersion}"
compile "io.grpc:grpc-netty:${grpcVersion}" compile "io.grpc:grpc-netty:${grpcVersion}"
compile "io.netty:netty-tcnative-boringssl-static:2.0.25.Final" compile "io.netty:netty-tcnative-boringssl-static:2.0.29.Final"
compile "io.netty:netty-all:4.1.36.Final" compile "io.netty:netty-all:4.1.48.Final"
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8" compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
compile "org.jetbrains.kotlin:kotlin-reflect" compile "org.jetbrains.kotlin:kotlin-reflect"
@@ -68,6 +68,7 @@ dependencies {
compile "org.springframework.security:spring-security-web:$springVersion" compile "org.springframework.security:spring-security-web:$springVersion"
compile "org.springframework.security:spring-security-config:$springVersion" compile "org.springframework.security:spring-security-config:$springVersion"
compile "io.projectreactor:reactor-core:$reactorVersion" compile "io.projectreactor:reactor-core:$reactorVersion"
compile "io.projectreactor.netty:reactor-netty:0.9.5.RELEASE"
compile 'io.projectreactor.addons:reactor-extra:3.2.3.RELEASE' compile 'io.projectreactor.addons:reactor-extra:3.2.3.RELEASE'
compile 'io.projectreactor.kotlin:reactor-kotlin-extensions:1.0.2.RELEASE' compile 'io.projectreactor.kotlin:reactor-kotlin-extensions:1.0.2.RELEASE'
compile 'com.salesforce.servicelibs:reactor-grpc:0.10.0' compile 'com.salesforce.servicelibs:reactor-grpc:0.10.0'

View File

@@ -29,32 +29,41 @@ And for a request:
- Is it for concrete data (_block #100_) or the latest (_get balance_)? - Is it for concrete data (_block #100_) or the latest (_get balance_)?
- Is result a static value (_just block_) or may vary depending on network and node (_latest nonce_)? - Is result a static value (_just block_) or may vary depending on network and node (_latest nonce_)?
- Does it need to be repeated over multiple nodes (_broadcast transaction_)? - Does it need to be repeated over multiple nodes (_broadcast transaction_)?
- Request can also specify which subset of nodes should be able to execute the request by selecting node _Labels_ - Request can also specify which subset of nodes should be able to execute the request by selecting node _Labels_ (see "link:08-quorum-and-selectors.adoc[Quorum and Selectors]")
(see "link:08-quorum-and-selectors.adoc[Quorum and Selectors]")
Based on these factors, Dshackle executes the request on a most optimal node. For most of the simple requests, it just gets a node that is synchronized Based on these factors, Dshackle executes the request on a most optimal node.
to the point that must have a response for that particular request. If node failed, returned an invalid response, returned For most of the simple requests, it just gets a node that is synchronized to the point that must have a response for that particular request.
an empty response when shouldn't then Dshackle tries again on another node, or on the same node after awhile (default is If node failed, returned an invalid response, returned an empty response when shouldn't then Dshackle tries again on another node, or on the same node after awhile (default is 200ms between failover repeats)
200ms between failover repeats)
=== Proxy
Dshackle provides access with standard JSON RPC protocol, functioning as a proxy to upstreams.
It provides:
- Routes only realy/alive upstreams, i.e., synchronized and with enough peers
- Load Balancing
- Request Retry on upstream errors
- Local Caching (memory and Redis, see link:09-caching.adoc[Caching])
- Broadcasting and Quorum for requests
=== gRPC protocol === gRPC protocol
Dshackle uses gRPC protocol for communications, because: Dshackle native protocol is based on gRPC, which provides many additional features:
- provides additional parameters on top of upstreams JSON RPC requests - provides additional parameters on top of upstreams JSON RPC requests
- based on HTTP/2 with low latency, compression, server push, pipelining and multiplexing - based on HTTP/2 with low latency, compression, server push, pipelining and multiplexing
- gRPC has binding and code generators for most of the languages and frameworks - gRPC has binding and code generators for most of the languages and frameworks
- easy to support TLS encryption and authentication - easy to support TLS encryption and authentication
Dshackle has extra methods and functionality on top of standard APIs of upstreams, and it's more flexible to wrap gRPC protocol is more flexible, and many of the Dshackle features are accessible mostly through that native protocol.
original APIs, such as JSON, into gRPC, and Protobuf + has additional data provided by Dshackle. It also allowed
to push new data from the server or send responses asynchronously, immediately after it gets executed on an upstream. Dshackle has extra methods and functionality on top of standard APIs of upstreams, and it's more flexible to wrap original APIs, such as JSON, into gRPC, and Protobuf + has additional data provided by Dshackle.
It also allowed to push new data from the server or send responses asynchronously, immediately after it gets executed on an upstream.
=== Distributed Load Balancing === Distributed Load Balancing
Dshackle servers can connect to each other through a secure encrypted and authenticated connection. Dshackle servers can connect to each other through a secure encrypted and authenticated connection.
It allows to build a network of nodes deployed to different regions or run blockchain nodes outside of the main It allows to build a network of nodes deployed to different regions or run blockchain nodes outside of the main network.
network.
Later is especially important for blockchain nodes, as they require an open firewall with incoming connections for P2P, and Later is especially important for blockchain nodes, as they require an open firewall with incoming connections for P2P, and
to execute untrusted code ("smart contracts") at the same time. With Dshackle, it's possible to separate insecure nodes from to execute untrusted code ("smart contracts") at the same time. With Dshackle, it's possible to separate insecure nodes from

View File

@@ -20,6 +20,14 @@ version: v1
port: 2449 port: 2449
tls: tls:
enabled: false enabled: false
proxy:
host: 0.0.0.0
port: 8545
routes:
- id: eth
blockchain: ethereum
- id: kovan
blockchain: kovan
upstreams: upstreams:
config: "upstreams.yaml" config: "upstreams.yaml"
---- ----
@@ -71,9 +79,30 @@ export INFURA_USER=...
.Run Dshackle .Run Dshackle
[source,bash] [source,bash]
---- ----
docker run -p 2449:2449 -v $(pwd):/etc/dshackle -e "INFURA_USER=$INFURA_USER" emeraldpay/dshackle docker run -p 2449:2449 -p 8545:8545 -v $(pwd):/etc/dshackle -e "INFURA_USER=$INFURA_USER" emeraldpay/dshackle
---- ----
==== Access using JSON RPC
Dshackle implements standard JSON RPC interface, providing additional caching layer, upstream readiness/liveness checks, retry and other features for building Fault Tolerant services.
.Request using Curl
[source,bash]
----
curl --request POST \
--url http://localhost:8545/eth \
--header 'content-type: application/json' \
--data '{"jsonrpc":"2.0", "method":"eth_getBalance", "id":1, "params":["0x690b2bdf41f33f9f251ae0459e5898b856ed96be", "latest"]}'
----
.Output
[source,bash]
----
{"jsonrpc":"2.0","id":1,"result":"0x72fa5e0181"}
----
==== Access using gRPC
.Connect and listen for new blocks on Ethereum Mainnet .Connect and listen for new blocks on Ethereum Mainnet
[source,bash] [source,bash]
---- ----
@@ -100,5 +129,5 @@ grpcurl -import-path ./proto/ -proto blockchain.proto -d "{\"type\": 100}" -plai
... ...
---- ----
The output above is for a _streaming subscription_ to all new blocks on Ethereum Mainnet. It's a method provided The output above is for a _streaming subscription_ to all new blocks on Ethereum Mainnet.
by Dshackle, provided in additional to methods provided by RPC JSON of underlying nodes. It's a method provided by Dshackle, available in additional to methods provided by RPC JSON of underlying nodes.

View File

@@ -3,8 +3,8 @@
Dshackle server tries to load its configuration from `/etc/dshackle/dshackle.yaml`, if it can't find a file at that path Dshackle server tries to load its configuration from `/etc/dshackle/dshackle.yaml`, if it can't find a file at that path
it tries to load file `dshackle.yaml` from current working directory. If none of them found server fails to run with an error. it tries to load file `dshackle.yaml` from current working directory. If none of them found server fails to run with an error.
[source,yaml]
.Example dshackle.yaml configuration: .Example dshackle.yaml configuration:
[source,yaml]
---- ----
version: v1 version: v1
port: 2449 port: 2449
@@ -22,8 +22,33 @@ upstreams:
It configures following: It configures following:
- server is listening on `0.0.0.0:2449` - server is listening with gRCP API on `0.0.0.0:2449`
- TLS is enabled - TLS is enabled
- server certificate is located at `server.crt` with the key for it at `server.p8.key` - server certificate is located at `server.crt` with the key for it at `server.p8.key`
- the server requires a client authentication by TLS client certificate signed by `ca.crt` certificate - the server requires a client authentication by TLS client certificate signed by `ca.crt` certificate
- no JSON RPC is configured
- upstreams configuration is configured in the file `upstreams.yaml` - upstreams configuration is configured in the file `upstreams.yaml`
=== Enabling JSON RPC proxy
.Example proxy:
[source,yaml]
----
version: v1
port: 2449
proxy:
host: 0.0.0.0
port: 8080
routes:
- id: eth
blockchain: ethereum
upstreams:
config: "upstreams.yaml"
----
With that configuration Dshackle starts a JSON RPC proxy:
- JSON RPC server is listening on `0.0.0.0:8080`
- `http://0.0.0.0:8080/eth` provides access to Ethereum API routed to an available upstream

View File

@@ -1,4 +1,4 @@
== Client Libraries == Client Libraries using native gRPC-based protocol
=== Protobuf === Protobuf

View File

@@ -12,7 +12,7 @@ protobufVersion=3.7.1
# Core # Core
springBootVersion=2.1.4.RELEASE springBootVersion=2.1.4.RELEASE
springVersion=5.1.4.RELEASE springVersion=5.1.4.RELEASE
reactorVersion=3.2.9.RELEASE reactorVersion=3.3.3.RELEASE
# Our Libs # Our Libs
etherjarVersion=0.9.1 etherjarVersion=0.9.1

View File

@@ -15,6 +15,8 @@
*/ */
package io.emeraldpay.dshackle package io.emeraldpay.dshackle
import io.emeraldpay.dshackle.config.ProxyConfig
import io.emeraldpay.dshackle.config.ProxyConfigReader
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean import org.springframework.beans.factory.config.YamlPropertiesFactoryBean
import org.springframework.core.env.* import org.springframework.core.env.*
@@ -23,7 +25,6 @@ import org.springframework.core.io.Resource
import org.springframework.core.io.support.ResourcePropertySource import org.springframework.core.io.support.ResourcePropertySource
import java.io.File import java.io.File
import java.util.* import java.util.*
import javax.annotation.PostConstruct
const val DEFAULT_CONFIG = "/etc/dshackle/dshackle.yaml" const val DEFAULT_CONFIG = "/etc/dshackle/dshackle.yaml"
const val LOCAL_CONFIG = "./dshackle.yaml" const val LOCAL_CONFIG = "./dshackle.yaml"
@@ -40,18 +41,25 @@ open class DshackleEnvironment: StandardEnvironment() {
propertySources.addLast(ResourcePropertySource("version.properties")) propertySources.addLast(ResourcePropertySource("version.properties"))
} }
open fun mainConfig(): PropertySource<*> { open fun getResource(): File? {
var target = File(DEFAULT_CONFIG) var target = File(DEFAULT_CONFIG)
if (!isAcceptedConfig(target)) { if (!isAcceptedConfig(target)) {
target = File(LOCAL_CONFIG) target = File(LOCAL_CONFIG)
if (!isAcceptedConfig(target)) { if (!isAcceptedConfig(target)) {
log.error("Configuration is not found neither at $DEFAULT_CONFIG nor $LOCAL_CONFIG") log.error("Configuration is not found neither at $DEFAULT_CONFIG nor $LOCAL_CONFIG")
return PropertySource.named("mainConfig") return null
} }
} }
target = target.normalize() target = target.normalize()
val loadedProperties = this.loadYaml(FileSystemResource(target)) return target
}
open fun mainConfig(): PropertySource<*> {
val target = getResource() ?: return PropertySource.named("mainConfig")
val resource = FileSystemResource(target)
val loadedProperties = this.loadYaml(resource)
loadedProperties["configPath"] = target.absolutePath loadedProperties["configPath"] = target.absolutePath
loadedProperties[ProxyConfig.CONFIG_ID] = extractYamlProxy(resource)
return PropertiesPropertySource("mainConfig", loadedProperties) return PropertiesPropertySource("mainConfig", loadedProperties)
} }
@@ -62,6 +70,11 @@ open class DshackleEnvironment: StandardEnvironment() {
return factory.getObject()!! return factory.getObject()!!
} }
protected fun extractYamlProxy(resource: Resource): ProxyConfig? {
val reader = ProxyConfigReader()
return reader.read(resource.inputStream)
}
protected fun isAcceptedConfig(target: File): Boolean { protected fun isAcceptedConfig(target: File): Boolean {
return target.exists() && target.isFile return target.exists() && target.isFile
} }

View File

@@ -45,7 +45,7 @@ open class GrpcServer(
log.info("Starting GRPC Server...") log.info("Starting GRPC Server...")
log.debug("Running with DEBUG LOGGING") log.debug("Running with DEBUG LOGGING")
val port = env.getProperty("port", "2449").toInt() val port = env.getProperty("port", "2449").toInt()
log.info("Listening on 0.0.0.0:$port") log.info("Listening Native on 0.0.0.0:$port")
val serverBuilder = NettyServerBuilder.forPort(port) val serverBuilder = NettyServerBuilder.forPort(port)
rpcs.forEach { rpcs.forEach {
serverBuilder.addService(it) serverBuilder.addService(it)

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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
import io.emeraldpay.dshackle.config.ProxyConfig
import io.emeraldpay.dshackle.proxy.ProxyServer
import io.emeraldpay.dshackle.proxy.ReadRpcJson
import io.emeraldpay.dshackle.proxy.WriteRpcJson
import io.emeraldpay.dshackle.rpc.NativeCall
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service
import javax.annotation.PostConstruct
/**
* Starts HTTP proxy endpoint, if configured
*/
@Service
class ProxyStarter(
@Autowired private val env: Environment,
@Autowired private val readRpcJson: ReadRpcJson,
@Autowired private val writeRpcJson: WriteRpcJson,
@Autowired private val nativeCall: NativeCall
) {
companion object {
private val log = LoggerFactory.getLogger(ProxyStarter::class.java)
}
@PostConstruct
fun start() {
val config = env.getProperty(ProxyConfig.CONFIG_ID, ProxyConfig::class.java)
if (config == null) {
log.debug("Proxy server is not configured")
return
}
val server = ProxyServer(config, readRpcJson, writeRpcJson, nativeCall)
server.start()
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.config
import org.yaml.snakeyaml.error.Mark
open class InvalidConfigException(
message: String
) : Exception(message)
class InvalidConfigYamlException(
filename: String,
mark: Mark,
message: String
) : InvalidConfigException("Invalid YAML configuration ${message}, at ${filename}:${mark.line}")

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.config
import io.emeraldpay.grpc.Chain
/**
* Configure HTTP Proxy to Upstreams
*/
class ProxyConfig {
companion object {
public const val CONFIG_ID = "parsed.proxy"
}
var enabled: Boolean = true
/**
* Host to bind server. Default: 127.0.0.1
*/
var host = "127.0.0.1"
/**
* Port to bind. Default: 8080
*/
var port: Int = 8080
/**
* List of available routes
*/
var routes: List<Route> = ArrayList()
class Route(
/**
* URL binding for the route. http://$host:$port/$id
*/
val id: String,
/**
* Blockchain to dispatch requests
*/
val blockchain: Chain
)
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.config
import io.emeraldpay.grpc.Chain
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.nodes.MappingNode
import java.io.InputStream
import java.io.InputStreamReader
/**
* Read YAML config, part related to Proxy configuration
*/
class ProxyConfigReader : YamlConfigReader() {
companion object {
private val log = LoggerFactory.getLogger(ProxyConfigReader::class.java)
}
private var filename = "dshackle.yaml"
fun read(input: InputStream): ProxyConfig? {
val yaml = Yaml()
val configNode = asMappingNode(yaml.compose(InputStreamReader(input)))
return read(getMapping(configNode, "proxy"))
}
fun read(input: MappingNode?): ProxyConfig? {
if (input == null) {
return null
}
val config = ProxyConfig()
getValueAsString(input, "host")?.let {
config.host = it
}
getValueAsInt(input, "port")?.let {
config.port = it
}
getValueAsBool(input, "enabled")?.let {
config.enabled = it
}
val currentRoutes = HashSet<String>()
getList<MappingNode>(input, "routes")?.let { routes ->
config.routes = routes.value.map { route ->
val id = getValueAsString(route, "id")
if (id == null || StringUtils.isEmpty(id) || !StringUtils.isAlphanumeric(id)) {
throw InvalidConfigYamlException(filename, route.startMark, "Route id must be alphanumeric")
}
if (currentRoutes.contains(id)) {
throw InvalidConfigYamlException(filename, route.startMark, "Route id repeated: $id")
}
currentRoutes.add(id)
val blockchain = getValueAsString(route, "blockchain")
if (StringUtils.isEmpty(blockchain) || getBlockchain(blockchain!!) == Chain.UNSPECIFIED) {
throw InvalidConfigYamlException(filename, route.startMark, "Invalid blockchain or not specified")
}
ProxyConfig.Route(id, getBlockchain(blockchain))
}
}
if (config.routes.isEmpty()) {
return null
}
return config
}
}

View File

@@ -29,10 +29,9 @@ import java.lang.IllegalArgumentException
import java.net.URI import java.net.URI
import java.time.Duration import java.time.Duration
class UpstreamsConfigReader { class UpstreamsConfigReader : YamlConfigReader() {
private val log = LoggerFactory.getLogger(UpstreamsConfigReader::class.java) private val log = LoggerFactory.getLogger(UpstreamsConfigReader::class.java)
private val envVariables = EnvVariables()
fun read(input: InputStream): UpstreamsConfig { fun read(input: InputStream): UpstreamsConfig {
val yaml = Yaml() val yaml = Yaml()
@@ -218,91 +217,4 @@ class UpstreamsConfigReader {
} }
} }
private fun hasAny(mappingNode: MappingNode?, key: String): Boolean {
if (mappingNode == null) {
return false
}
return mappingNode.value
.stream()
.filter { n -> n.keyNode is ScalarNode }
.filter { n ->
val sn = n.keyNode as ScalarNode
key == sn.value
}.count() > 0
}
private fun <T> getValue(mappingNode: MappingNode?, key: String, type: Class<T>): T? {
if (mappingNode == null) {
return null
}
return mappingNode.value
.stream()
.filter { n -> n.keyNode is ScalarNode && type.isAssignableFrom(n.valueNode.javaClass) }
.filter { n ->
val sn = n.keyNode as ScalarNode
key == sn.value
}
.map { n -> n.valueNode as T }
.findFirst().let {
if (it.isPresent) {
it.get()
} else {
null
}
}
}
private fun getMapping(mappingNode: MappingNode?, key: String): MappingNode? {
return getValue(mappingNode, key, MappingNode::class.java)
}
private fun getValue(mappingNode: MappingNode?, key: String): ScalarNode? {
return getValue(mappingNode, key, ScalarNode::class.java)
}
private fun <T> getList(mappingNode: MappingNode?, key: String): CollectionNode<T>? {
val value = getValue(mappingNode, key, CollectionNode::class.java) ?: return null
return value as CollectionNode<T>
}
private fun getListOfString(mappingNode: MappingNode?, key: String): List<String>? {
return getList<ScalarNode>(mappingNode, key)?.value
?.map { it.value }
?.map(envVariables::postProcess)
}
private fun getValueAsString(mappingNode: MappingNode?, key: String): String? {
return getValue(mappingNode, key)?.let {
return@let it.value
}?.let(envVariables::postProcess)
}
private fun getValueAsInt(mappingNode: MappingNode?, key: String): Int? {
return getValue(mappingNode, key)?.let {
return@let if (it.isPlain) {
it.value.toIntOrNull()
} else {
null
}
}
}
private fun getValueAsBool(mappingNode: MappingNode?, key: String): Boolean? {
return getValue(mappingNode, key)?.let {
return@let if (it.isPlain) {
it.value?.toLowerCase() == "true"
} else {
null
}
}
}
private fun asMappingNode(node: Node): MappingNode {
return if (MappingNode::class.java.isAssignableFrom(node.javaClass)) {
node as MappingNode
} else {
throw IllegalArgumentException("Not a map")
}
}
} }

View File

@@ -0,0 +1,123 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.config
import io.emeraldpay.grpc.Chain
import org.yaml.snakeyaml.nodes.CollectionNode
import org.yaml.snakeyaml.nodes.MappingNode
import org.yaml.snakeyaml.nodes.Node
import org.yaml.snakeyaml.nodes.ScalarNode
open class YamlConfigReader {
private val envVariables = EnvVariables()
protected fun hasAny(mappingNode: MappingNode?, key: String): Boolean {
if (mappingNode == null) {
return false
}
return mappingNode.value
.stream()
.filter { n -> n.keyNode is ScalarNode }
.filter { n ->
val sn = n.keyNode as ScalarNode
key == sn.value
}.count() > 0
}
private fun <T> getValue(mappingNode: MappingNode?, key: String, type: Class<T>): T? {
if (mappingNode == null) {
return null
}
return mappingNode.value
.stream()
.filter { n -> n.keyNode is ScalarNode && type.isAssignableFrom(n.valueNode.javaClass) }
.filter { n ->
val sn = n.keyNode as ScalarNode
key == sn.value
}
.map { n -> n.valueNode as T }
.findFirst().let {
if (it.isPresent) {
it.get()
} else {
null
}
}
}
protected fun getMapping(mappingNode: MappingNode?, key: String): MappingNode? {
return getValue(mappingNode, key, MappingNode::class.java)
}
private fun getValue(mappingNode: MappingNode?, key: String): ScalarNode? {
return getValue(mappingNode, key, ScalarNode::class.java)
}
protected fun <T> getList(mappingNode: MappingNode?, key: String): CollectionNode<T>? {
val value = getValue(mappingNode, key, CollectionNode::class.java) ?: return null
return value as CollectionNode<T>
}
protected fun getListOfString(mappingNode: MappingNode?, key: String): List<String>? {
return getList<ScalarNode>(mappingNode, key)?.value
?.map { it.value }
?.map(envVariables::postProcess)
}
protected fun getValueAsString(mappingNode: MappingNode?, key: String): String? {
return getValue(mappingNode, key)?.let {
return@let it.value
}?.let(envVariables::postProcess)
}
protected fun getValueAsInt(mappingNode: MappingNode?, key: String): Int? {
return getValue(mappingNode, key)?.let {
return@let if (it.isPlain) {
it.value.toIntOrNull()
} else {
null
}
}
}
protected fun getValueAsBool(mappingNode: MappingNode?, key: String): Boolean? {
return getValue(mappingNode, key)?.let {
return@let if (it.isPlain) {
it.value?.toLowerCase() == "true"
} else {
null
}
}
}
protected fun asMappingNode(node: Node): MappingNode {
return if (MappingNode::class.java.isAssignableFrom(node.javaClass)) {
node as MappingNode
} else {
throw IllegalArgumentException("Not a map")
}
}
// ----
fun getBlockchain(id: String): Chain {
return Chain.values().find { chain ->
chain.name == id.toUpperCase()
|| chain.chainCode.toUpperCase() == id.toUpperCase()
|| chain.id.toString() == id
} ?: Chain.UNSPECIFIED
}
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import io.emeraldpay.api.proto.BlockchainOuterClass
import org.slf4j.LoggerFactory
/**
* JSON RPC call to the proxy
*/
class ProxyCall(
/**
* Type of the request. The response format depends on it
*/
val type: RpcType
) {
companion object {
private val log = LoggerFactory.getLogger(ProxyCall::class.java)
}
/**
* Mapping from our internal ids to user provided JSON RPC ids.
*/
val ids = HashMap<Int, Any>()
/**
* Content of the request
*/
val items: MutableList<BlockchainOuterClass.NativeCallItem> = ArrayList()
enum class RpcType {
/**
* One item request passed as Object
*/
SINGLE,
/**
* Batch passed as Array of Object. It may be one-element array, i.e., single request, though response
* must be formatted as an Array
*/
BATCH
}
}

View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.Common
import io.emeraldpay.dshackle.config.ProxyConfig
import io.emeraldpay.dshackle.rpc.NativeCall
import io.netty.buffer.Unpooled
import org.reactivestreams.Publisher
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import reactor.core.publisher.Mono
import reactor.netty.DisposableServer
import reactor.netty.http.server.HttpServer
import reactor.netty.http.server.HttpServerRequest
import reactor.netty.http.server.HttpServerResponse
import reactor.netty.http.server.HttpServerRoutes
import java.util.function.BiFunction
/**
* HTTP Proxy Server
*/
class ProxyServer(
private var config: ProxyConfig,
private val readRpcJson: ReadRpcJson,
private val writeRpcJson: WriteRpcJson,
private val nativeCall: NativeCall
) {
companion object {
private val log = LoggerFactory.getLogger(ProxyServer::class.java)
}
fun start() {
if (!config.enabled) {
log.debug("Proxy server is not enabled")
return
}
log.info("Listening Proxy on ${config.host}:${config.port}")
val server: DisposableServer = HttpServer.create()
.host(config.host)
.port(config.port)
.route(this::setupRoutes)
.bindNow()
}
fun setupRoutes(routes: HttpServerRoutes) {
config.routes.forEach { routeConfig ->
routes.post("/" + routeConfig.id, proxy(routeConfig))
}
}
fun execute(chain: Common.ChainRef, call: ProxyCall): Publisher<String> {
val request = BlockchainOuterClass.NativeCallRequest.newBuilder()
.setChain(chain)
.addAllItems(call.items)
.build()
val jsons = nativeCall
.nativeCall(Mono.just(request))
.transform(writeRpcJson.toJsons(call))
return if (call.type == ProxyCall.RpcType.SINGLE) {
jsons.next()
} else {
jsons.transform(writeRpcJson.asArray())
}
}
fun proxy(routeConfig: ProxyConfig.Route): BiFunction<HttpServerRequest, HttpServerResponse, Publisher<Void>> {
val chain = Common.ChainRef.forNumber(routeConfig.blockchain.id)
return BiFunction { req, resp ->
val results = req.receive()
.aggregate()
.asByteArray()
.map(readRpcJson)
.flatMapMany { call -> execute(chain, call) }
.map { Unpooled.wrappedBuffer(it.toByteArray()) }
resp.addHeader(HttpHeaders.CONTENT_TYPE, "application/json")
.send(results)
}
}
}

View File

@@ -0,0 +1,145 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.infinitape.etherjar.rpc.RpcException
import io.infinitape.etherjar.rpc.RpcResponseError
import io.infinitape.etherjar.rpc.json.RequestJson
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import java.io.IOException
import java.util.*
import java.util.function.Function
import java.util.stream.Collectors
/**
* Reader for JSON RPC request
*/
@Service
open class ReadRpcJson(
@Autowired private val objectMapper: ObjectMapper
) : Function<ByteArray, ProxyCall> {
companion object {
private val log = LoggerFactory.getLogger(ReadRpcJson::class.java)
private val spaces = " \n\t".toByteArray()
}
private val jsonExtractor: Function<Map<*, *>, RequestJson<Any>>
init {
jsonExtractor = Function { json ->
if ("2.0" != json["jsonrpc"]) {
throw RpcException(RpcResponseError.CODE_INVALID_REQUEST, "Unsupported JSON RPC version")
}
if (json["id"] == null) {
throw RpcException(RpcResponseError.CODE_INVALID_REQUEST, "ID not set")
}
val id = json["id"]
if (!(json["method"] != null && json["method"] is String)) {
throw RpcException(RpcResponseError.CODE_INVALID_REQUEST, "ID not set")
}
if (json.containsKey("params") && json["params"] !is List<*>) {
throw RpcException(RpcResponseError.CODE_INVALID_REQUEST, "Params must be an array")
}
RequestJson<Any>(
json["method"].toString(),
json["params"] as List<*>,
id
)
}
}
/**
* Read fist non-space character, which supposed to start actual JSON part of the request
*/
@Throws(IOException::class)
fun getStartOfJson(buf: ByteArray): Byte {
val count = buf.size
var i = 0
//if cannot find anything in the first 255 bytes, just consider it as invalid
while (i < 256 && i < count) {
if (buf[i] != spaces[0] && buf[i] != spaces[1] && buf[i] != spaces[2]) {
return buf[i]
}
i++
}
throw IllegalArgumentException("Invalid input")
}
/**
* Check the type of the payload, based on the format (first character at this case)
*/
@Throws(IOException::class)
fun getType(data: ByteArray): ProxyCall.RpcType {
val first = try {
getStartOfJson(data)
} catch (e: IllegalArgumentException) {
throw RpcException(RpcResponseError.CODE_INVALID_JSON, "Empty JSON")
}
if (first == '{'.toByte()) {
return ProxyCall.RpcType.SINGLE
} else if (first == '['.toByte()) {
return ProxyCall.RpcType.BATCH
}
throw RpcException(RpcResponseError.CODE_INVALID_JSON, "Failed to parse JSON")
}
/**
* Convert payload to the proxy call details
*/
override fun apply(data: ByteArray): ProxyCall {
val list: MutableList<Map<*, *>>
try {
val type = getType(data)
if (ProxyCall.RpcType.BATCH == type) {
list = objectMapper.readerFor(MutableList::class.java).readValue(data)
} else {
list = ArrayList(1)
val json = objectMapper.readerFor(MutableMap::class.java).readValue<Map<*, *>>(data)
list.add(json)
}
val context = ProxyCall(type)
// our internal ids for calls
var seq = 0
val batch = list.stream()
.map<RequestJson<Any>>(jsonExtractor)
.map { json ->
val id = seq++
context.ids[id] = json.id
BlockchainOuterClass.NativeCallItem.newBuilder()
.setId(id)
.setMethod(json.method)
.setPayload(ByteString.copyFrom(objectMapper.writeValueAsBytes(json.params)))
.build()
}
.collect(Collectors.toList())
context.items.addAll(batch)
return context
} catch (e: RpcException) {
throw e
} catch (e: Exception) {
log.error("Parse Error: " + e.message)
throw RpcException(RpcResponseError.CODE_INVALID_JSON, e.message)
}
}
}

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.infinitape.etherjar.rpc.RpcResponseError
import io.infinitape.etherjar.rpc.json.ResponseJson
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.lang.StringBuilder
import java.time.Duration
import java.util.function.Function
/**
* Writer for JSON RPC requests
*/
@Service
open class WriteRpcJson(
@Autowired private val objectMapper: ObjectMapper
) {
companion object {
private val log = LoggerFactory.getLogger(WriteRpcJson::class.java)
}
/**
* Convert Dshackle protobuf based responses to JSON RPC formatted as strings
*/
open fun toJsons(call: ProxyCall): Function<Flux<BlockchainOuterClass.NativeCallReplyItem>, Flux<String>> {
return Function { flux ->
flux.flatMap { response ->
val json = ResponseJson<Any, Any>()
if (!call.ids.containsKey(response.id)) {
log.warn("ID wasn't requested: ${response.id}")
return@flatMap Flux.empty<String>()
}
json.id = call.ids[response.id]
if (response.succeed) {
val payload = objectMapper.readValue(response.payload.toByteArray(), ResponseJson::class.java)
if (payload.error != null) {
json.error = payload.error
} else {
json.result = payload.result
}
} else {
json.error = RpcResponseError(-32002, response.errorMessage)
}
Flux.just(objectMapper.writeValueAsString(json))
}.onErrorContinue { t, u ->
log.warn("Failed to convert to JSON", t)
}
}
}
/**
* Format response as JSON Array, for Batch requests
*/
fun asArray(): Function<Flux<String>, Flux<String>> {
return Function { flux ->
val body = flux.zipWith(Flux.concat(Mono.just(false), Flux.just(true).repeat()))
.map {
if (it.t2) {
"," + it.t1
} else {
it.t1
}
}
Flux.concat(
Mono.just("["),
body,
Mono.just("]")
)
}
}
}

View File

@@ -33,7 +33,7 @@ import reactor.util.function.Tuples
import java.lang.Exception import java.lang.Exception
@Service @Service
class NativeCall( open class NativeCall(
@Autowired private val upstreams: Upstreams, @Autowired private val upstreams: Upstreams,
@Autowired private val objectMapper: ObjectMapper @Autowired private val objectMapper: ObjectMapper
) { ) {
@@ -42,8 +42,8 @@ class NativeCall(
open fun nativeCall(requestMono: Mono<BlockchainOuterClass.NativeCallRequest>): Flux<BlockchainOuterClass.NativeCallReplyItem> { open fun nativeCall(requestMono: Mono<BlockchainOuterClass.NativeCallRequest>): Flux<BlockchainOuterClass.NativeCallReplyItem> {
return requestMono.flatMapMany(this::prepareCall) return requestMono.flatMapMany(this::prepareCall)
.map(this::setupCallParams) .map(this::setupCallParams)
.parallel() .parallel()
.flatMap(this::fetch) .flatMap(this::fetch)
.sequential() .sequential()
.map(this::buildResponse) .map(this::buildResponse)

View File

@@ -0,0 +1,67 @@
package io.emeraldpay.dshackle.config
import io.emeraldpay.grpc.Chain
import spock.lang.Specification
class ProxyConfigReaderSpec extends Specification {
ProxyConfigReader reader = new ProxyConfigReader()
def "Read basic proxy config"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("dshackle-proxy-basic.yaml")
when:
def act = reader.read(config)
then:
act.enabled
act.port == 8080
act.host == '127.0.0.1'
act.routes.size() == 1
with(act.routes[0]) {
id == "ethereum"
blockchain == Chain.ETHEREUM
}
}
def "Read proxy config with two elements"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("dshackle-proxy-two.yaml")
when:
def act = reader.read(config)
then:
act.enabled
act.port == 8080
act.routes.size() == 2
with(act.routes[0]) {
id == "ethereum"
blockchain == Chain.ETHEREUM
}
with(act.routes[1]) {
id == "classic"
blockchain == Chain.ETHEREUM_CLASSIC
}
}
def "Read max proxy config"() {
setup:
def config = this.class.getClassLoader().getResourceAsStream("dshackle-proxy-max.yaml")
when:
def act = reader.read(config)
then:
act.enabled
act.host == '0.0.0.0'
act.port == 8080
act.routes.size() == 2
with(act.routes[0]) {
id == "ethereum"
blockchain == Chain.ETHEREUM
}
with(act.routes[1]) {
id == "classic"
blockchain == Chain.ETHEREUM_CLASSIC
}
}
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.Common
import io.emeraldpay.dshackle.config.ProxyConfig
import io.emeraldpay.dshackle.rpc.NativeCall
import io.emeraldpay.dshackle.test.TestingCommons
import reactor.core.publisher.Flux
import reactor.test.StepVerifier
import spock.lang.Specification
import java.time.Duration
import java.util.function.Function
class ProxyServerSpec extends Specification {
def "Uses NativeCall"() {
setup:
NativeCall nativeCall = Mock(NativeCall)
def predefined = { a -> Flux.just("hello") } as Function
WriteRpcJson writeRpcJson = Mock {
1 * toJsons(_) >> predefined
}
ProxyServer server = new ProxyServer(
new ProxyConfig(),
new ReadRpcJson(TestingCommons.objectMapper()),
writeRpcJson,
nativeCall
)
def call = new ProxyCall(ProxyCall.RpcType.SINGLE)
call.ids[1] = 1
call.items.add(
BlockchainOuterClass.NativeCallItem.newBuilder()
.setMethod("eth_hello")
.build()
)
when:
def act = server.execute(Common.ChainRef.CHAIN_ETHEREUM, call)
then:
1 * nativeCall.nativeCall(_) >> Flux.just(BlockchainOuterClass.NativeCallReplyItem.newBuilder().build())
StepVerifier.create(act)
.expectNext("hello")
.expectComplete()
.verify(Duration.ofSeconds(1))
}
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import io.emeraldpay.dshackle.test.TestingCommons
import io.infinitape.etherjar.rpc.RpcException
import spock.lang.Specification
class ReadRpcJsonSpec extends Specification {
ReadRpcJson reader = new ReadRpcJson(TestingCommons.objectMapper())
def "Get first symbol"() {
expect:
reader.getStartOfJson(input.bytes) == exp.bytes[0]
where:
exp | input
"{" | "{}"
"{" | " { }"
"{" | "\n\n { }"
"[" | " [ { } ] "
}
def "Error for input with many spaces"() {
setup:
def empty = " " * 1000
when:
reader.getStartOfJson((empty + "{}").bytes)
then:
thrown(IllegalArgumentException)
}
def "Error for empty spaces"() {
when:
reader.getStartOfJson("".bytes)
then:
thrown(IllegalArgumentException)
}
def "Get type"() {
expect:
reader.getType(input.bytes) == exp
where:
exp | input
ProxyCall.RpcType.SINGLE | "{}"
ProxyCall.RpcType.SINGLE | " { }"
ProxyCall.RpcType.SINGLE | "\n\n { }"
ProxyCall.RpcType.BATCH | " [ { } ] "
}
def "Error type for invalid input"() {
when:
reader.getType("hello".bytes)
then:
thrown(RpcException)
when:
reader.getType("1".bytes)
then:
thrown(RpcException)
when:
reader.getType("".bytes)
then:
thrown(RpcException)
}
}

View File

@@ -0,0 +1,178 @@
/**
* Copyright (c) 2020 ETCDEV GmbH
*
* 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.proxy
import com.google.protobuf.ByteString
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.dshackle.test.TestingCommons
import reactor.core.publisher.Flux
import spock.lang.Specification
import java.time.Duration
class WriteRpcJsonSpec extends Specification {
WriteRpcJson writer = new WriteRpcJson(TestingCommons.objectMapper())
def "Write empty array"() {
when:
def act = Flux.empty().transform(writer.asArray())
.collectList()
.block(Duration.ofSeconds(1))
.join("")
then:
act == "[]"
}
def "Write single item array"() {
when:
def act = Flux.just('{"id": 1}').transform(writer.asArray())
.collectList()
.block(Duration.ofSeconds(1))
.join("")
then:
act == '[{"id": 1}]'
}
def "Write two item array"() {
setup:
def data = [
'{"id": 1}',
'{"id": 2}',
]
when:
def act = Flux.fromIterable(data).transform(writer.asArray())
.collectList()
.block(Duration.ofSeconds(1))
.join("")
then:
act == '[{"id": 1},{"id": 2}]'
}
def "Write few items array"() {
setup:
def data = [
'{"id": 1}',
'{"id": 2, "foo": "bar"}',
'{"id": 3, "foo": "baz"}',
'{"id": 4}',
'{"id": 5, "x": 5}',
]
when:
def act = Flux.fromIterable(data).transform(writer.asArray())
.collectList()
.block(Duration.ofSeconds(1))
.join("")
then:
act == '[{"id": 1},{"id": 2, "foo": "bar"},{"id": 3, "foo": "baz"},{"id": 4},{"id": 5, "x": 5}]'
}
def "Convert basic to JSON"() {
setup:
def call = new ProxyCall(ProxyCall.RpcType.SINGLE)
call.ids[1] = "aaa"
def data = [
BlockchainOuterClass.NativeCallReplyItem.newBuilder()
.setId(1)
.setSucceed(true)
.setPayload(ByteString.copyFrom('{"jsonrpc": "2.0", "id": 1, "result": "0x98dbb1"}', 'UTF-8'))
.build()
]
when:
def act = Flux.fromIterable(data)
.transform(writer.toJsons(call))
.collectList()
.block(Duration.ofSeconds(1))
then:
act == ['{"jsonrpc":"2.0","id":"aaa","result":"0x98dbb1"}']
}
def "Convert error to JSON"() {
setup:
def call = new ProxyCall(ProxyCall.RpcType.SINGLE)
call.ids[1] = 1
def data = [
BlockchainOuterClass.NativeCallReplyItem.newBuilder()
.setId(1)
.setSucceed(true)
.setPayload(ByteString.copyFrom('{"jsonrpc": "2.0", "id": 1, "error": {"code": -32001, "message": "oops"}}', 'UTF-8'))
.build()
]
when:
def act = Flux.fromIterable(data)
.transform(writer.toJsons(call))
.collectList()
.block(Duration.ofSeconds(1))
then:
act == ['{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"oops"}}']
}
def "Convert gRPC error to JSON"() {
setup:
def call = new ProxyCall(ProxyCall.RpcType.SINGLE)
call.ids[1] = 1
def data = [
BlockchainOuterClass.NativeCallReplyItem.newBuilder()
.setId(1)
.setSucceed(false)
.setErrorMessage("Internal Error")
.build()
]
when:
def act = Flux.fromIterable(data)
.transform(writer.toJsons(call))
.collectList()
.block(Duration.ofSeconds(1))
then:
act == ['{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"Internal Error"}}']
}
def "Convert few items to JSON"() {
setup:
def call = new ProxyCall(ProxyCall.RpcType.SINGLE)
call.ids[1] = 10
call.ids[2] = 11
call.ids[3] = 15
def data = [
BlockchainOuterClass.NativeCallReplyItem.newBuilder()
.setId(1)
.setSucceed(true)
.setPayload(ByteString.copyFrom('{"jsonrpc": "2.0", "id": 1, "result": "0x98dbb1"}', 'UTF-8'))
.build(),
BlockchainOuterClass.NativeCallReplyItem.newBuilder()
.setId(2)
.setSucceed(true)
.setPayload(ByteString.copyFrom('{"jsonrpc": "2.0", "id": 2, "error": {"code": -32001, "message": "oops"}}', 'UTF-8'))
.build(),
BlockchainOuterClass.NativeCallReplyItem.newBuilder()
.setId(3)
.setSucceed(true)
.setPayload(ByteString.copyFrom('{"jsonrpc": "2.0", "id": 3, "result": {"hash": "0x2484f459dc"}}', 'UTF-8'))
.build(),
]
when:
def act = Flux.fromIterable(data)
.transform(writer.toJsons(call))
.collectList()
.block(Duration.ofSeconds(1))
then:
act == [
'{"jsonrpc":"2.0","id":10,"result":"0x98dbb1"}',
'{"jsonrpc":"2.0","id":11,"error":{"code":-32001,"message":"oops"}}',
'{"jsonrpc":"2.0","id":15,"result":{"hash":"0x2484f459dc"}}'
]
}
}

View File

@@ -0,0 +1,5 @@
proxy:
port: 8080
routes:
- id: ethereum
blockchain: ethereum

View File

@@ -0,0 +1,9 @@
proxy:
enabled: true
host: 0.0.0.0
port: 8080
routes:
- id: ethereum
blockchain: ethereum
- id: classic
blockchain: etc

View File

@@ -0,0 +1,7 @@
proxy:
port: 8080
routes:
- id: ethereum
blockchain: ethereum
- id: classic
blockchain: etc