diff --git a/server/app/com/xsn/explorer/modules/ExecutorsModule.scala b/server/app/com/xsn/explorer/modules/ExecutorsModule.scala new file mode 100644 index 0000000..f95c528 --- /dev/null +++ b/server/app/com/xsn/explorer/modules/ExecutorsModule.scala @@ -0,0 +1,11 @@ +package com.xsn.explorer.modules + +import com.google.inject.AbstractModule +import com.xsn.explorer.executors.{ExternalServiceAkkaExecutionContext, ExternalServiceExecutionContext} + +class ExecutorsModule extends AbstractModule { + + override def configure(): Unit = { + bind(classOf[ExternalServiceExecutionContext]).to(classOf[ExternalServiceAkkaExecutionContext]) + } +} diff --git a/server/app/com/xsn/explorer/modules/XSNServiceModule.scala b/server/app/com/xsn/explorer/modules/XSNServiceModule.scala new file mode 100644 index 0000000..87879a9 --- /dev/null +++ b/server/app/com/xsn/explorer/modules/XSNServiceModule.scala @@ -0,0 +1,11 @@ +package com.xsn.explorer.modules + +import com.google.inject.AbstractModule +import com.xsn.explorer.services.{XSNService, XSNServiceRPCImpl} + +class XSNServiceModule extends AbstractModule { + + override def configure(): Unit = { + bind(classOf[XSNService]).to(classOf[XSNServiceRPCImpl]) + } +} diff --git a/server/app/com/xsn/explorer/services/XSNService.scala b/server/app/com/xsn/explorer/services/XSNService.scala new file mode 100644 index 0000000..b8dba80 --- /dev/null +++ b/server/app/com/xsn/explorer/services/XSNService.scala @@ -0,0 +1,88 @@ +package com.xsn.explorer.services + +import javax.inject.Inject + +import com.alexitc.playsonify.core.FutureApplicationResult +import com.alexitc.playsonify.models.ApplicationError +import com.xsn.explorer.config.RPCConfig +import com.xsn.explorer.errors.{TransactionNotFoundError, XSNMessageError, XSNUnexpectedResponseError} +import com.xsn.explorer.executors.ExternalServiceExecutionContext +import com.xsn.explorer.models.TransactionId +import org.scalactic.{Bad, Good} +import org.slf4j.LoggerFactory +import play.api.libs.json.{JsNull, JsValue} +import play.api.libs.ws.{WSAuthScheme, WSClient} + +import scala.util.Try + +trait XSNService { + + def getTransaction(txid: TransactionId): FutureApplicationResult[JsValue] +} + +class XSNServiceRPCImpl @Inject() ( + ws: WSClient, + rpcConfig: RPCConfig)( + implicit ec: ExternalServiceExecutionContext) + extends XSNService { + + private val logger = LoggerFactory.getLogger(this.getClass) + + private val server = ws.url(rpcConfig.host.string) + .withAuth(rpcConfig.username.string, rpcConfig.password.string, WSAuthScheme.BASIC) + .withHttpHeaders("Content-Type" -> "text/plain") + + // TODO: cache successful results + override def getTransaction(txid: TransactionId): FutureApplicationResult[JsValue] = { + server + .post(s"""{ "jsonrpc": "1.0", "method": "getrawtransaction", "params": ["${txid.string}", 1] }""") + .map { response => + + val maybe = Try(response.json) + .toOption + .flatMap { json => + (json \ "result") + .asOpt[JsValue] + .filter(_ != JsNull) + .map { Good(_) } + .orElse { + mapError(json) + .map(Bad.apply) + .map(_.accumulating) + } + } + + maybe.getOrElse { + logger.warn(s"Unexpected response from XSN Server, txid = ${txid.string}, response = ${response.body}") + + Bad(XSNUnexpectedResponseError).accumulating + } + } + } + + private def mapError(json: JsValue): Option[ApplicationError] = { + val jsonErrorMaybe = (json \ "error") + .asOpt[JsValue] + .filter(_ != JsNull) + + jsonErrorMaybe + .flatMap { jsonError => + // from error code if possible + (jsonError \ "code") + .asOpt[Int] + .flatMap(fromErrorCode) + .orElse { + // from message + (jsonError \ "message") + .asOpt[String] + .filter(_.nonEmpty) + .map(XSNMessageError.apply) + } + } + } + + private def fromErrorCode(code: Int): Option[ApplicationError] = code match { + case -5 => Some(TransactionNotFoundError) + case _ => None + } +} diff --git a/server/conf/application.conf b/server/conf/application.conf index f0bf526..a86bff3 100644 --- a/server/conf/application.conf +++ b/server/conf/application.conf @@ -24,6 +24,8 @@ rpc { } play.modules.enabled += "com.xsn.explorer.modules.ConfigModule" +play.modules.enabled += "com.xsn.explorer.modules.ExecutorsModule" +play.modules.enabled += "com.xsn.explorer.modules.XSNServiceModule" externalService.dispatcher { executor = "thread-pool-executor" diff --git a/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala b/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala new file mode 100644 index 0000000..e81ed2a --- /dev/null +++ b/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala @@ -0,0 +1,131 @@ +package com.xsn.explorer.services + +import com.xsn.explorer.config.RPCConfig +import com.xsn.explorer.errors.{TransactionNotFoundError, XSNMessageError, XSNUnexpectedResponseError} +import com.xsn.explorer.helpers.Executors +import com.xsn.explorer.models.TransactionId +import org.mockito.ArgumentMatchers._ +import org.mockito.Mockito._ +import org.scalactic.Bad +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.mockito.MockitoSugar +import org.scalatest.{MustMatchers, WordSpec} +import play.api.libs.json.Json +import play.api.libs.ws.{WSClient, WSRequest, WSResponse} + +import scala.concurrent.Future + +class XSNServiceRPCImplSpec extends WordSpec with MustMatchers with ScalaFutures with MockitoSugar { + + val ws = mock[WSClient] + val ec = Executors.externalServiceEC + val config = new RPCConfig { + override def password: RPCConfig.Password = RPCConfig.Password("pass") + override def host: RPCConfig.Host = RPCConfig.Host("localhost") + override def username: RPCConfig.Username = RPCConfig.Username("user") + } + + val request = mock[WSRequest] + val response = mock[WSResponse] + when(ws.url(anyString)).thenReturn(request) + when(request.withAuth(anyString(), anyString(), any())).thenReturn(request) + when(request.withHttpHeaders(any())).thenReturn(request) + + val service = new XSNServiceRPCImpl(ws, config)(ec) + val txid = TransactionId.from("024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c").get + + "getTransaction" should { + "handle a successful result" in { + val responseBody = + """ + |{ + | "result": { + | "blockhash": "000003fb382f6892ae96594b81aa916a8923c70701de4e7054aac556c7271ef7", + | "blocktime": 1520276270, + | "confirmations": 5347, + | "height": 1, + | "hex": "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff03510101ffffffff010000000000000000232103e8c52f2c5155771492907095753a43ce776e1fa7c5e769a67a9f3db4467ec029ac00000000", + | "locktime": 0, + | "size": 98, + | "time": 1520276270, + | "txid": "024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c", + | "version": 1, + | "vin": [ + | { + | "coinbase": "510101", + | "sequence": 4294967295 + | } + | ], + | "vout": [ + | { + | "n": 0, + | "scriptPubKey": { + | "addresses": [ + | "XdJnCKYNwzCz8ATv8Eu75gonaHyfr9qXg9" + | ], + | "asm": "03e8c52f2c5155771492907095753a43ce776e1fa7c5e769a67a9f3db4467ec029 OP_CHECKSIG", + | "hex": "2103e8c52f2c5155771492907095753a43ce776e1fa7c5e769a67a9f3db4467ec029ac", + | "reqSigs": 1, + | "type": "pubkey" + | }, + | "value": 0, + | "valueSat": 0 + | } + | ] + | }, + | "error": null, + | "id": null + |} + """.stripMargin.trim + + val json = Json.parse(responseBody) + + when(response.status).thenReturn(200) + when(response.json).thenReturn(json) + when(request.post[String](anyString())(any())).thenReturn(Future.successful(response)) + + whenReady(service.getTransaction(txid)) { result => + result.isGood mustEqual true + } + } + + "handle transaction not found" in { + val responseBody = """{"result":null,"error":{"code":-5,"message":"No information available about transaction"},"id":null}""" + val json = Json.parse(responseBody) + when(response.status).thenReturn(200) + when(response.json).thenReturn(json) + when(request.post[String](anyString())(any())).thenReturn(Future.successful(response)) + + whenReady(service.getTransaction(txid)) { result => + result mustEqual Bad(TransactionNotFoundError).accumulating + } + } + + "handle error with message" in { + val responseBody = """{"result":null,"error":{"code":-32600,"message":"Params must be an array"},"id":null}""" + val json = Json.parse(responseBody) + + when(response.status).thenReturn(200) + when(response.json).thenReturn(json) + when(request.post[String](anyString())(any())).thenReturn(Future.successful(response)) + + whenReady(service.getTransaction(txid)) { result => + val error = XSNMessageError("Params must be an array") + result mustEqual Bad(error).accumulating + } + } + + "handle unexpected error" in { + val responseBody = """{"result":null,"error":{},"id":null}""" + val json = Json.parse(responseBody) + + when(response.status).thenReturn(200) + when(response.json).thenReturn(json) + when(request.post[String](anyString())(any())).thenReturn(Future.successful(response)) + + whenReady(service.getTransaction(txid)) { result => + result mustEqual Bad(XSNUnexpectedResponseError).accumulating + } + } + } +}