From 9a20aade0a00f6ef4ad01247e34bbc4cbe4101cd Mon Sep 17 00:00:00 2001 From: Anton Kumaigorodski Date: Wed, 19 May 2021 18:47:06 +0300 Subject: [PATCH] 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. --- .../src/main/scala/fr/acinq/eclair/Boot.scala | 21 +++++------ .../main/scala/fr/acinq/eclair/Plugin.scala | 7 ++++ .../scala/fr/acinq/eclair/api/Service.scala | 11 ++---- .../api/directives/EclairDirectives.scala | 2 +- .../fr/acinq/eclair/api/ApiServiceSpec.scala | 35 ++++++++++++++++--- 5 files changed, 52 insertions(+), 24 deletions(-) diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala index 4c8b64766..22356c64f 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala @@ -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 { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/Plugin.scala b/eclair-node/src/main/scala/fr/acinq/eclair/Plugin.scala index 990364dc9..d7e410043 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/Plugin.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/Plugin.scala @@ -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 { diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala index 3c0a6e16c..70e22c7e9 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -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)(_ ~ _) } - } diff --git a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala index 1c6b1965a..93f455126 100644 --- a/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala +++ b/eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala @@ -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 => /** diff --git a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala index 63728a572..57632393d 100644 --- a/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala +++ b/eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala @@ -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(