5 changed files with 243 additions and 0 deletions
@ -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]) |
||||
|
} |
||||
|
} |
@ -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]) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue