1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-02-23 06:35:11 +01:00

Improve JSON RPC API error handling (#322)

Service pattern matching code visually separates each method and
params to improve the code readability and maintenance. Route completion
is handle on a case by case basis, for each call. This enables better error
management and useful feedback to the caller.

Added custom rejections to handle cases where the given rpc method or
params are not found or not correct.

HTTP code should now be consistent with the error returned.
This commit is contained in:
Dominique 2017-12-22 21:01:40 +01:00 committed by Pierre-Marie Padiou
parent 6a5814d4fe
commit 059f211916
5 changed files with 195 additions and 105 deletions

View file

@ -128,6 +128,7 @@ java -Declair.datadir=/tmp/node1 -jar eclair-node-gui-<version>-<commit_id>.jar
open | nodeId, host, port, fundingSatoshis, pushMsat | opens a channel with another lightning node
peers | | list existing local peers
channels | | list existing local channels
channels | nodeId | list existing local channels opened with a particular nodeId
channel | channelId | retrieve detailed information about a given channel
allnodes | | list all known nodes
allchannels | | list all known channels
@ -136,6 +137,8 @@ java -Declair.datadir=/tmp/node1 -jar eclair-node-gui-<version>-<commit_id>.jar
send | amountMsat, paymentHash, nodeId | send a payment to a lightning node
send | paymentRequest | send a payment to a lightning node using a BOLT11 payment request
send | paymentRequest, amountMsat | send a payment to a lightning node using a BOLT11 payment request and a custom amount
checkpayment| paymentHash | returns true if the payment has been received, false otherwise
checkpayment| paymentRequest | returns true if the payment has been received, false otherwise
close | channelId | close a channel
close | channelId, scriptPubKey (optional) | close a channel and send the funds to the given scriptPubKey
help | | display available methods

View file

@ -18,8 +18,13 @@ case $1 in
"channels")
eval curl "$CURL_OPTS -d '{ \"method\": \"channels\", \"params\" : [] }' $URL" | jq ".result[]"
;;
"channelsto")
eval curl "$CURL_OPTS -d '{ \"method\": \"channelsto\", \"params\" : [\"${2?"missing node id"}\"] }' $URL" | jq ".result[]"
"channels")
if [ $# -ge 2 ]
then
eval curl "$CURL_OPTS -d '{ \"method\": \"channels\", \"params\" : [\"${2?"missing node id"}\"] }' $URL" | jq ".result[]"
else
eval curl "$CURL_OPTS -d '{ \"method\": \"channels\", \"params\" : [] }' $URL" | jq ".result[]"
fi
;;
"channel")
eval curl "$CURL_OPTS -d '{ \"method\": \"channel\", \"params\" : [\"${2?"missing channel id"}\"] }' $URL" | jq ".result | { nodeid, channelId, state, balanceMsat: .data.commitments.localCommit.spec.toLocalMsat, capacitySat: .data.commitments.commitInput.txOut.amount.amount }"
@ -45,4 +50,7 @@ case $1 in
"peers")
eval curl "$CURL_OPTS -d '{ \"method\": \"peers\", \"params\" : [] }' $URL" | jq ".result"
;;
"checkpayment")
eval curl "$CURL_OPTS -d '{ \"method\": \"checkpayment\", \"params\" : [\"${2?"missing payment request or payment hash"}\"] }' $URL" | jq ".result"
;;
esac

View file

@ -7,12 +7,12 @@ import scala.util.{Failure, Success, Try}
object DBCompatChecker extends Logging {
/**
* Tests if the DB files are compatible with the current version of eclair; throws an exception if incompatible.
* Tests if the channels data in the DB are compatible with the current version of eclair; throws an exception if incompatible.
*
* @param nodeParams
*/
def checkDBCompatibility(nodeParams: NodeParams): Unit =
Try(nodeParams.networkDb.listChannels() ++ nodeParams.networkDb.listNodes() ++ nodeParams.peersDb.listPeers() ++ nodeParams.channelsDb.listChannels()) match {
Try(nodeParams.channelsDb.listChannels()) match {
case Success(_) => {}
case Failure(_) => throw IncompatibleDBException
}

View file

@ -10,7 +10,8 @@ import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`,
import akka.http.scaladsl.model.headers.HttpOriginRange.*
import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.server.directives.RouteDirectives.reject
import akka.http.scaladsl.server.{ExceptionHandler, Rejection, RejectionHandler, Route}
import akka.pattern.ask
import akka.util.Timeout
import de.heikoseeberger.akkahttpjson4s.Json4sSupport
@ -37,6 +38,13 @@ case class JsonRPCRes(result: AnyRef, error: Option[Error], id: String)
case class Status(node_id: String)
case class GetInfoResponse(nodeId: PublicKey, alias: String, port: Int, chainHash: BinaryData, blockHeight: Int)
case class ChannelInfo(shortChannelId: String, nodeId1: PublicKey, nodeId2: PublicKey)
trait RPCRejection extends Rejection {
def requestId: String
}
final case class UnknownMethodRejection(requestId: String) extends RPCRejection
final case class UnknownParamsRejection(requestId: String, message: String) extends RPCRejection
final case class NotFoundRejection(requestId: String) extends RPCRejection
final case class ValidationRejection(requestId: String, message: String) extends RPCRejection
// @formatter:on
trait Service extends Logging {
@ -52,14 +60,182 @@ trait Service extends Logging {
def appKit: Kit
def getInfoResponse: Future[GetInfoResponse]
val customHeaders = `Access-Control-Allow-Origin`(*) ::
`Access-Control-Allow-Headers`("Content-Type, Authorization") ::
`Access-Control-Allow-Methods`(PUT, GET, POST, DELETE, OPTIONS) ::
`Cache-Control`(public, `no-store`, `max-age`(0)) ::
`Access-Control-Allow-Headers`("x-requested-with") :: Nil
val myExceptionHandler = ExceptionHandler {
case t: Throwable =>
extractRequest { request =>
logger.info(s"API call failed with cause=${t.getMessage}")
complete(StatusCodes.InternalServerError, JsonRPCRes(null, Some(Error(StatusCodes.InternalServerError.intValue, t.getMessage)), request.asInstanceOf[JsonRPCBody].id))
}
}
def completeRpcFuture(requestId: String, future: Future[AnyRef]): Route = onComplete(future) {
case Success(s) => completeRpc(requestId, s)
case Failure(_) => reject
}
def completeRpc(requestId: String, result: AnyRef): Route = complete(JsonRPCRes(result, None, requestId))
val myRejectionHandler: RejectionHandler = RejectionHandler.newBuilder()
.handleNotFound {
complete(StatusCodes.NotFound, JsonRPCRes(null, Some(Error(StatusCodes.NotFound.intValue, "not found")), "-1"))
}
.handle {
case v: ValidationRejection complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, v.message)), v.requestId))
case nf: NotFoundRejection complete(StatusCodes.NotFound, JsonRPCRes(null, Some(Error(StatusCodes.NotFound.intValue, "not found")), nf.requestId))
case ukm: UnknownMethodRejection complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, "method not found")), ukm.requestId))
case p: UnknownParamsRejection complete(StatusCodes.BadRequest,
JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"invalid parameters for this method, should be: ${p.message}")), p.requestId))
case r logger.error(s"API call failed with cause=$r")
complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, r.toString)), "-1"))
}
.result()
val route: Route =
respondWithDefaultHeaders(customHeaders) {
handleExceptions(myExceptionHandler) {
handleRejections(myRejectionHandler) {
pathSingleSlash {
post {
entity(as[JsonRPCBody]) {
req =>
val kit = appKit
import kit._
req.method match {
// utility methods
case "getinfo" => completeRpcFuture(req.id, getInfoResponse)
case "help" => completeRpc(req.id, help)
// channel lifecycle methods
case "connect" => req.params match {
case JString(nodeId) :: JString(host) :: JInt(port) :: Nil =>
completeRpcFuture(req.id, (switchboard ? NewConnection(PublicKey(nodeId), new InetSocketAddress(host, port.toInt), None)).mapTo[String])
case _ => reject(UnknownParamsRejection(req.id, "[nodeId, host, port]"))
}
case "open" => req.params match {
case JString(nodeId) :: JString(host) :: JInt(port) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: JInt(newChannel) :: Nil =>
completeRpcFuture(req.id, (switchboard ? NewConnection(PublicKey(nodeId), new InetSocketAddress(host, port.toInt), Some(NewChannel(Satoshi(fundingSatoshi.toLong),
MilliSatoshi(pushMsat.toLong), Some(newChannel.toByte))))).mapTo[String])
case JString(nodeId) :: JString(host) :: JInt(port) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: Nil =>
completeRpcFuture(req.id, (switchboard ? NewConnection(PublicKey(nodeId), new InetSocketAddress(host, port.toInt), Some(NewChannel(Satoshi(fundingSatoshi.toLong),
MilliSatoshi(pushMsat.toLong), None)))).mapTo[String])
case _ => reject(UnknownParamsRejection(req.id, "[nodeId, host, port, fundingSatoshi, pushMsat] or [nodeId, host, port, fundingSatoshi, pushMsat, newChannel]"))
}
case "close" => req.params match {
case JString(identifier) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String])
case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(scriptPubKey))).mapTo[String])
case _ => reject(UnknownParamsRejection(req.id, "[channelId] or [channelId, scriptPubKey]"))
}
// local network methods
case "peers" => completeRpcFuture(req.id, (switchboard ? 'peers).mapTo[Map[PublicKey, ActorRef]].map(_.map(_._1.toBin)))
case "channels" => req.params match {
case Nil => completeRpcFuture(req.id, (register ? 'channels).mapTo[Map[Long, ActorRef]].map(_.keys))
case JString(remoteNodeId) :: Nil => Try(PublicKey(remoteNodeId)) match {
case Success(pk) => completeRpcFuture(req.id, (register ? 'channelsTo).mapTo[Map[BinaryData, PublicKey]].map(_.filter(_._2 == pk).keys))
case Failure(f) => reject(ValidationRejection(req.id, s"invalid remote node id '$remoteNodeId'"))
}
case _ => reject(UnknownParamsRejection(req.id, "no arguments or [remoteNodeId]"))
}
case "channel" => req.params match {
case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_GETINFO).mapTo[RES_GETINFO])
case _ => reject(UnknownParamsRejection(req.id, "[channelId]"))
}
// global network methods
case "allnodes" => completeRpcFuture(req.id, (router ? 'nodes).mapTo[Iterable[NodeAnnouncement]].map(_.map(_.nodeId)))
case "allchannels" => completeRpcFuture(req.id, (router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelInfo(c.shortChannelId.toHexString, c.nodeId1, c.nodeId2))))
// payment methods
case "receive" => req.params match {
// only the payment description is given: user may want to generate a donation payment request
case JString(description) :: Nil =>
completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write))
// the amount is now given with the description
case JInt(amountMsat) :: JString(description) :: Nil =>
completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write))
case _ => reject(UnknownParamsRejection(req.id, "[description] or [amount, description]"))
}
case "send" => req.params match {
// user manually sets the payment information
case JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil =>
(Try(BinaryData(paymentHash)), Try(PublicKey(nodeId))) match {
case (Success(ph), Success(pk)) => completeRpcFuture(req.id, (paymentInitiator ? SendPayment(amountMsat.toLong, ph, pk)).mapTo[PaymentResult])
case (Failure(_), _) => reject(ValidationRejection(req.id, s"invalid payment hash '$paymentHash'"))
case _ => reject(ValidationRejection(req.id, s"invalid node id '$nodeId'"))
}
// user gives a Lightning payment request
case JString(paymentRequest) :: rest => Try(PaymentRequest.read(paymentRequest)) match {
case Success(pr) =>
// setting the payment amount
val amount_msat: Long = (pr.amount, rest) match {
// optional amount always overrides the amount in the payment request
case (_, JInt(amount_msat_override) :: Nil) => amount_msat_override.toLong
case (Some(amount_msat_pr), _) => amount_msat_pr.amount
case _ => throw new RuntimeException("you must manually specify an amount for this payment request")
}
logger.debug(s"api call for sending payment with amount_msat=$amount_msat")
// optional cltv expiry
val sendPayment = pr.minFinalCltvExpiry match {
case None => SendPayment(amount_msat, pr.paymentHash, pr.nodeId)
case Some(minFinalCltvExpiry) => SendPayment(amount_msat, pr.paymentHash, pr.nodeId, assistedRoutes = Nil, minFinalCltvExpiry)
}
completeRpcFuture(req.id, (paymentInitiator ? sendPayment).mapTo[PaymentResult])
case _ => reject(ValidationRejection(req.id, s"payment request is not valid"))
}
case _ => reject(UnknownParamsRejection(req.id, "[amountMsat, paymentHash, nodeId or [paymentRequest] or [paymentRequest, amountMsat]"))
}
// check received payments
case "checkpayment" => req.params match {
case JString(identifier) :: Nil => completeRpcFuture(req.id, for {
paymentHash <- Try(PaymentRequest.read(identifier)) match {
case Success(pr) => Future.successful(pr.paymentHash)
case _ => Try(BinaryData(identifier)) match {
case Success(s) => Future.successful(s)
case _ => Future.failed(new IllegalArgumentException("payment identifier must be a payment request or a payment hash"))
}
}
found <- (paymentHandler ? CheckPayment(paymentHash)).map(found => new JBool(found.asInstanceOf[Boolean]))
} yield found)
case _ => reject(UnknownParamsRejection(req.id, "[paymentHash] or [paymentRequest]"))
}
// method name was not found
case _ => reject(UnknownMethodRejection(req.id))
}
}
}
}
}
}
}
def getInfoResponse: Future[GetInfoResponse]
def help = List("connect (nodeId, host, port): connect to another lightning node through a secure connection",
"open (nodeId, host, port, fundingSatoshi, pushMsat, channelFlags = 0x01): open a channel with another lightning node",
"peers: list existing local peers",
"channels: list existing local channels",
"channels (nodeId): list existing local channels to a particular nodeId",
"channel (channelId): retrieve detailed information about a given channel",
"allnodes: list all known nodes",
"allchannels: list all known channels",
"receive (amountMsat, description): generate a payment request for a given amount",
"send (amountMsat, paymentHash, nodeId): send a payment to a lightning node",
"send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request",
"send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount",
"close (channelId): close a channel",
"close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey",
"checkpayment (paymentHash): returns true if the payment has been received, false otherwise",
"checkpayment (paymentRequest): returns true if the payment has been received, false otherwise",
"help: display this message")
/**
* Sends a request to a channel and expects a response
*
@ -74,102 +250,4 @@ trait Service extends Logging {
.recoverWith { case _ => Future.failed(new RuntimeException(s"invalid channel identifier '$channelIdentifier'")) }
res <- appKit.register ? fwdReq
} yield res
val route: Route =
respondWithDefaultHeaders(customHeaders) {
pathSingleSlash {
post {
entity(as[JsonRPCBody]) {
req =>
val kit = appKit
import kit._
val f_res: Future[AnyRef] = req match {
case JsonRPCBody(_, _, "getinfo", _) => getInfoResponse
case JsonRPCBody(_, _, "connect", JString(nodeId) :: JString(host) :: JInt(port) :: Nil) =>
(switchboard ? NewConnection(PublicKey(nodeId), new InetSocketAddress(host, port.toInt), None)).mapTo[String]
case JsonRPCBody(_, _, "open", JString(nodeId) :: JString(host) :: JInt(port) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: options) =>
val channelFlags = options match {
case JInt(value) :: Nil => Some(value.toByte)
case _ => None // TODO: too lax?
}
(switchboard ? NewConnection(PublicKey(nodeId), new InetSocketAddress(host, port.toInt), Some(NewChannel(Satoshi(fundingSatoshi.toLong), MilliSatoshi(pushMsat.toLong), channelFlags)))).mapTo[String]
case JsonRPCBody(_, _, "peers", _) =>
(switchboard ? 'peers).mapTo[Map[PublicKey, ActorRef]].map(_.map(_._1.toBin))
case JsonRPCBody(_, _, "channels", _) =>
(register ? 'channels).mapTo[Map[Long, ActorRef]].map(_.keys)
case JsonRPCBody(_, _, "channelsto", JString(remoteNodeId) :: Nil) =>
val remotePubKey = Try(PublicKey(remoteNodeId)).getOrElse(throw new RuntimeException(s"invalid remote node id '$remoteNodeId'"))
(register ? 'channelsTo).mapTo[Map[BinaryData, PublicKey]].map(_.filter(_._2 == remotePubKey).keys)
case JsonRPCBody(_, _, "channel", JString(identifier) :: Nil) =>
sendToChannel(identifier, CMD_GETINFO).mapTo[RES_GETINFO]
case JsonRPCBody(_, _, "allnodes", _) =>
(router ? 'nodes).mapTo[Iterable[NodeAnnouncement]].map(_.map(_.nodeId))
case JsonRPCBody(_, _, "allchannels", _) =>
(router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelInfo(c.shortChannelId.toHexString, c.nodeId1, c.nodeId2)))
case JsonRPCBody(_, _, "receive", JString(description) :: Nil) =>
(paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write)
case JsonRPCBody(_, _, "receive", JInt(amountMsat) :: JString(description) :: Nil) =>
(paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write)
case JsonRPCBody(_, _, "send", JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil) =>
(paymentInitiator ? SendPayment(amountMsat.toLong, paymentHash, PublicKey(nodeId))).mapTo[PaymentResult]
case JsonRPCBody(_, _, "send", JString(paymentRequest) :: rest) =>
for {
req <- Future(PaymentRequest.read(paymentRequest))
amountMsat = (req.amount, rest) match {
case (Some(_), JInt(amt) :: Nil) => amt.toLong // overriding payment request amount with the one provided
case (Some(amt), _) => amt.amount
case (None, JInt(amt) :: Nil) => amt.toLong // amount wasn't specified in request, using custom one
case (None, _) => throw new RuntimeException("you need to manually specify an amount for this payment request")
}
sendPayment = req.minFinalCltvExpiry match {
case None => SendPayment(amountMsat, req.paymentHash, req.nodeId, req.routingInfo())
case Some(minFinalCltvExpiry) => SendPayment(amountMsat, req.paymentHash, req.nodeId, req.routingInfo(), minFinalCltvExpiry = minFinalCltvExpiry)
}
res <- (paymentInitiator ? sendPayment).mapTo[PaymentResult]
} yield res
case JsonRPCBody(_, _, "close", JString(identifier) :: JString(scriptPubKey) :: Nil) =>
sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(scriptPubKey))).mapTo[String]
case JsonRPCBody(_, _, "close", JString(identifier) :: Nil) =>
sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]
case JsonRPCBody(_, _, "checkpayment", JString(identifier) :: Nil) =>
for {
paymentHash <- Try(PaymentRequest.read(identifier)) match {
case Success(pr) => Future.successful(pr.paymentHash)
case _ => Try(BinaryData(identifier)) match {
case Success(s) => Future.successful(s)
case _ => Future.failed(new IllegalArgumentException("payment identifier must be a payment request or a payment hash"))
}
}
found <- (paymentHandler ? CheckPayment(paymentHash)).map(found => new JBool(found.asInstanceOf[Boolean]))
} yield found
case JsonRPCBody(_, _, "help", _) =>
Future.successful(List(
"connect (nodeId, host, port): connect to another lightning node through a secure connection",
"open (nodeId, host, port, fundingSatoshi, pushMsat, channelFlags = 0x01): open a channel with another lightning node",
"peers: list existing local peers",
"channels: list existing local channels",
"channelsto (nodeId): list existing local channels to a particular nodeId",
"channel (channelId): retrieve detailed information about a given channel",
"allnodes: list all known nodes",
"allchannels: list all known channels",
"receive (amountMsat, description): generate a payment request for a given amount",
"send (amountMsat, paymentHash, nodeId): send a payment to a lightning node",
"send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request",
"send (paymentRequest, amountMsat): send a payment to a lightning node using a BOLT11 payment request and a custom amount",
"close (channelId): close a channel",
"close (channelId, scriptPubKey): close a channel and send the funds to the given scriptPubKey",
"checkpayment (paymentHash): returns true if the payment has been received, false otherwise",
"checkpayment (paymentRequest): returns true if the payment has been received, false otherwise",
"help: display this message"))
case _ => Future.failed(new RuntimeException("method not found"))
}
onComplete(f_res) {
case Success(res) => complete(JsonRPCRes(res, None, req.id))
case Failure(t) => complete(StatusCodes.InternalServerError, JsonRPCRes(null, Some(Error(-1, t.getMessage)), req.id))
}
}
}
}
}
}

View file

@ -106,6 +106,7 @@
<arg>-language:postfixOps</arg>
<arg>-language:implicitConversions</arg>
<arg>-Xfatal-warnings</arg>
<arg>-unchecked</arg>
</args>
<scalaCompatVersion>${scala.version.short}</scalaCompatVersion>
</configuration>