1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-02-23 22:46:44 +01:00

Allow plugins to inject their own routes into API (#1805)

Plugins can extend the `RouteProvider` trait to enrich the API with
custom calls, removing the need to setup a separate endpoint on a
different port.

When routes clash between plugins, the second one is simply ignored.
Plugin developers should prepend their route with their plugin name
to avoid such silent clashes.
This commit is contained in:
Anton Kumaigorodski 2021-05-19 18:47:06 +03:00 committed by GitHub
parent 76894bd2e1
commit 9a20aade0a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 52 additions and 24 deletions

View file

@ -20,7 +20,8 @@ import java.io.File
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.stream.{ActorMaterializer, BindFailedException}
import akka.http.scaladsl.server.Route
import akka.stream.BindFailedException
import fr.acinq.eclair.api.Service
import grizzled.slf4j.Logging
import kamon.Kamon
@ -50,7 +51,8 @@ object Boot extends App with Logging {
plugins.foreach(_.onSetup(setup))
setup.bootstrap onComplete {
case Success(kit) =>
startApiServiceIfEnabled(kit)
val routeProviderPlugins = plugins.collect { case plugin: RouteProvider => plugin }
startApiServiceIfEnabled(kit, routeProviderPlugins)
plugins.foreach(_.onKit(kit))
case Failure(t) => onError(t)
}
@ -65,22 +67,21 @@ object Boot extends App with Logging {
* @param system
* @param ec
*/
def startApiServiceIfEnabled(kit: Kit)(implicit system: ActorSystem, ec: ExecutionContext) = {
def startApiServiceIfEnabled(kit: Kit, providers: Seq[RouteProvider] = Nil)(implicit system: ActorSystem, ec: ExecutionContext) = {
val config = system.settings.config.getConfig("eclair")
if (config.getBoolean("api.enabled")) {
logger.info(s"json API enabled on port=${config.getInt("api.port")}")
implicit val materializer = ActorMaterializer()
val apiPassword = config.getString("api.password") match {
case "" => throw EmptyAPIPasswordException
case valid => valid
}
val apiRoute = new Service {
override val actorSystem = system
override val mat = materializer
override val password = apiPassword
val service: Service = new Service {
override val actorSystem: ActorSystem = system
override val password: String = apiPassword
override val eclairApi: Eclair = new EclairImpl(kit)
}.route
Http().bindAndHandle(apiRoute, config.getString("api.binding-ip"), config.getInt("api.port")).recover {
}
val pluginRoutes = providers.map(_.route(service))
Http().bindAndHandle(service.finalRoutes(pluginRoutes), config.getString("api.binding-ip"), config.getInt("api.port")).recover {
case _: BindFailedException => onError(TCPBindException(config.getInt("api.port")))
}
} else {

View file

@ -18,7 +18,11 @@ package fr.acinq.eclair
import java.io.File
import java.net.{JarURLConnection, URL, URLClassLoader}
import akka.http.scaladsl.server.Route
import fr.acinq.eclair.api.directives.EclairDirectives
import grizzled.slf4j.Logging
import scala.util.{Failure, Success, Try}
trait Plugin {
@ -28,7 +32,10 @@ trait Plugin {
def onSetup(setup: Setup): Unit
def onKit(kit: Kit): Unit
}
trait RouteProvider {
def route(directives: EclairDirectives): Route
}
object Plugin extends Logging {

View file

@ -18,7 +18,6 @@ package fr.acinq.eclair.api
import akka.actor.ActorSystem
import akka.http.scaladsl.server._
import akka.stream.Materializer
import fr.acinq.eclair.Eclair
import fr.acinq.eclair.api.directives.EclairDirectives
import fr.acinq.eclair.api.handlers._
@ -41,18 +40,12 @@ trait Service extends EclairDirectives with WebSocket with Node with Channel wit
*/
implicit val actorSystem: ActorSystem
/**
* Materializer for sending and receiving tcp streams.
*/
implicit val mat: Materializer
/**
* Collect routes from all sub-routers here.
* This is the main entrypoint for the global http request router of the API service.
* This is where we handle errors to ensure all routes are correctly tried before rejecting.
*/
val route: Route = securedHandler {
nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket
def finalRoutes(extraRoutes: Seq[Route]): Route = securedHandler {
extraRoutes.foldLeft(nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket)(_ ~ _)
}
}

View file

@ -22,7 +22,7 @@ import fr.acinq.eclair.api.Service
import scala.concurrent.duration.DurationInt
class EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with AuthDirective with DefaultHeaders with ExtraDirectives {
trait EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with AuthDirective with DefaultHeaders with ExtraDirectives {
this: Service =>
/**

View file

@ -16,13 +16,14 @@
package fr.acinq.eclair.api
import java.util.UUID
import akka.actor.{ActorRef, ActorSystem}
import akka.http.scaladsl.model.FormData
import akka.http.scaladsl.model.StatusCodes._
import akka.http.scaladsl.model.headers.BasicHttpCredentials
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest, WSProbe}
import akka.stream.Materializer
import akka.util.Timeout
import de.heikoseeberger.akkahttpjson4s.Json4sSupport
import fr.acinq.bitcoin.Crypto.PublicKey
@ -31,7 +32,7 @@ import fr.acinq.eclair.ApiTypes.ChannelIdentifier
import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional}
import fr.acinq.eclair.Features.{ChannelRangeQueriesExtended, OptionDataLossProtect}
import fr.acinq.eclair._
import fr.acinq.eclair.api.directives.ErrorResponse
import fr.acinq.eclair.api.directives.{EclairDirectives, ErrorResponse}
import fr.acinq.eclair.api.serde.JsonSupport
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.ChannelOpenResponse.ChannelOpened
@ -52,7 +53,6 @@ import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scodec.bits._
import java.util.UUID
import scala.concurrent.Future
import scala.concurrent.duration._
import scala.io.Source
@ -68,13 +68,29 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
val aliceNodeId = PublicKey(hex"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0")
val bobNodeId = PublicKey(hex"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585")
object PluginApi extends RouteProvider {
override def route(directives: EclairDirectives): Route = {
import directives._
val route1 = postRequest("plugin-test") { implicit t =>
complete("fine")
}
val route2 = postRequest("payinvoice") { implicit t =>
complete("gets overridden by base API endpoint")
}
route1 ~ route2
}
}
class MockService(eclair: Eclair) extends Service {
override val eclairApi: Eclair = eclair
override def password: String = "mock"
override implicit val actorSystem: ActorSystem = system
override implicit val mat: Materializer = materializer
val route: Route = finalRoutes(Seq(PluginApi.route(this)))
}
def mockApi(eclair: Eclair = mock[Eclair]): MockService = {
@ -170,6 +186,17 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
}
}
test("plugin injects its own route") {
Post("/plugin-test") ~>
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
Route.seal(mockApi().route) ~>
check {
assert(handled)
assert(status == OK)
assert(entityAs[String] == "fine")
}
}
test("'usablebalances' asks relayer for current usable balances") {
val eclair = mock[Eclair]
eclair.usableBalances()(any[Timeout]) returns Future.successful(List(