Skip to content

Commit 9a20aad

Browse files
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.
1 parent 76894bd commit 9a20aad

File tree

5 files changed

+52
-24
lines changed

5 files changed

+52
-24
lines changed

eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala

+11-10
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import java.io.File
2020

2121
import akka.actor.ActorSystem
2222
import akka.http.scaladsl.Http
23-
import akka.stream.{ActorMaterializer, BindFailedException}
23+
import akka.http.scaladsl.server.Route
24+
import akka.stream.BindFailedException
2425
import fr.acinq.eclair.api.Service
2526
import grizzled.slf4j.Logging
2627
import kamon.Kamon
@@ -50,7 +51,8 @@ object Boot extends App with Logging {
5051
plugins.foreach(_.onSetup(setup))
5152
setup.bootstrap onComplete {
5253
case Success(kit) =>
53-
startApiServiceIfEnabled(kit)
54+
val routeProviderPlugins = plugins.collect { case plugin: RouteProvider => plugin }
55+
startApiServiceIfEnabled(kit, routeProviderPlugins)
5456
plugins.foreach(_.onKit(kit))
5557
case Failure(t) => onError(t)
5658
}
@@ -65,22 +67,21 @@ object Boot extends App with Logging {
6567
* @param system
6668
* @param ec
6769
*/
68-
def startApiServiceIfEnabled(kit: Kit)(implicit system: ActorSystem, ec: ExecutionContext) = {
70+
def startApiServiceIfEnabled(kit: Kit, providers: Seq[RouteProvider] = Nil)(implicit system: ActorSystem, ec: ExecutionContext) = {
6971
val config = system.settings.config.getConfig("eclair")
7072
if (config.getBoolean("api.enabled")) {
7173
logger.info(s"json API enabled on port=${config.getInt("api.port")}")
72-
implicit val materializer = ActorMaterializer()
7374
val apiPassword = config.getString("api.password") match {
7475
case "" => throw EmptyAPIPasswordException
7576
case valid => valid
7677
}
77-
val apiRoute = new Service {
78-
override val actorSystem = system
79-
override val mat = materializer
80-
override val password = apiPassword
78+
val service: Service = new Service {
79+
override val actorSystem: ActorSystem = system
80+
override val password: String = apiPassword
8181
override val eclairApi: Eclair = new EclairImpl(kit)
82-
}.route
83-
Http().bindAndHandle(apiRoute, config.getString("api.binding-ip"), config.getInt("api.port")).recover {
82+
}
83+
val pluginRoutes = providers.map(_.route(service))
84+
Http().bindAndHandle(service.finalRoutes(pluginRoutes), config.getString("api.binding-ip"), config.getInt("api.port")).recover {
8485
case _: BindFailedException => onError(TCPBindException(config.getInt("api.port")))
8586
}
8687
} else {

eclair-node/src/main/scala/fr/acinq/eclair/Plugin.scala

+7
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ package fr.acinq.eclair
1818

1919
import java.io.File
2020
import java.net.{JarURLConnection, URL, URLClassLoader}
21+
22+
import akka.http.scaladsl.server.Route
23+
import fr.acinq.eclair.api.directives.EclairDirectives
2124
import grizzled.slf4j.Logging
25+
2226
import scala.util.{Failure, Success, Try}
2327

2428
trait Plugin {
@@ -28,7 +32,10 @@ trait Plugin {
2832
def onSetup(setup: Setup): Unit
2933

3034
def onKit(kit: Kit): Unit
35+
}
3136

37+
trait RouteProvider {
38+
def route(directives: EclairDirectives): Route
3239
}
3340

3441
object Plugin extends Logging {

eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala

+2-9
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ package fr.acinq.eclair.api
1818

1919
import akka.actor.ActorSystem
2020
import akka.http.scaladsl.server._
21-
import akka.stream.Materializer
2221
import fr.acinq.eclair.Eclair
2322
import fr.acinq.eclair.api.directives.EclairDirectives
2423
import fr.acinq.eclair.api.handlers._
@@ -41,18 +40,12 @@ trait Service extends EclairDirectives with WebSocket with Node with Channel wit
4140
*/
4241
implicit val actorSystem: ActorSystem
4342

44-
/**
45-
* Materializer for sending and receiving tcp streams.
46-
*/
47-
implicit val mat: Materializer
48-
4943
/**
5044
* Collect routes from all sub-routers here.
5145
* This is the main entrypoint for the global http request router of the API service.
5246
* This is where we handle errors to ensure all routes are correctly tried before rejecting.
5347
*/
54-
val route: Route = securedHandler {
55-
nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket
48+
def finalRoutes(extraRoutes: Seq[Route]): Route = securedHandler {
49+
extraRoutes.foldLeft(nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket)(_ ~ _)
5650
}
57-
5851
}

eclair-node/src/main/scala/fr/acinq/eclair/api/directives/EclairDirectives.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import fr.acinq.eclair.api.Service
2222

2323
import scala.concurrent.duration.DurationInt
2424

25-
class EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with AuthDirective with DefaultHeaders with ExtraDirectives {
25+
trait EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with AuthDirective with DefaultHeaders with ExtraDirectives {
2626
this: Service =>
2727

2828
/**

eclair-node/src/test/scala/fr/acinq/eclair/api/ApiServiceSpec.scala

+31-4
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@
1616

1717
package fr.acinq.eclair.api
1818

19+
import java.util.UUID
20+
1921
import akka.actor.{ActorRef, ActorSystem}
2022
import akka.http.scaladsl.model.FormData
2123
import akka.http.scaladsl.model.StatusCodes._
2224
import akka.http.scaladsl.model.headers.BasicHttpCredentials
2325
import akka.http.scaladsl.server.Route
2426
import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest, WSProbe}
25-
import akka.stream.Materializer
2627
import akka.util.Timeout
2728
import de.heikoseeberger.akkahttpjson4s.Json4sSupport
2829
import fr.acinq.bitcoin.Crypto.PublicKey
@@ -31,7 +32,7 @@ import fr.acinq.eclair.ApiTypes.ChannelIdentifier
3132
import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional}
3233
import fr.acinq.eclair.Features.{ChannelRangeQueriesExtended, OptionDataLossProtect}
3334
import fr.acinq.eclair._
34-
import fr.acinq.eclair.api.directives.ErrorResponse
35+
import fr.acinq.eclair.api.directives.{EclairDirectives, ErrorResponse}
3536
import fr.acinq.eclair.api.serde.JsonSupport
3637
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
3738
import fr.acinq.eclair.channel.ChannelOpenResponse.ChannelOpened
@@ -52,7 +53,6 @@ import org.scalatest.funsuite.AnyFunSuite
5253
import org.scalatest.matchers.should.Matchers
5354
import scodec.bits._
5455

55-
import java.util.UUID
5656
import scala.concurrent.Future
5757
import scala.concurrent.duration._
5858
import scala.io.Source
@@ -68,13 +68,29 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
6868
val aliceNodeId = PublicKey(hex"03af0ed6052cf28d670665549bc86f4b721c9fdb309d40c58f5811f63966e005d0")
6969
val bobNodeId = PublicKey(hex"039dc0e0b1d25905e44fdf6f8e89755a5e219685840d0bc1d28d3308f9628a3585")
7070

71+
object PluginApi extends RouteProvider {
72+
override def route(directives: EclairDirectives): Route = {
73+
import directives._
74+
val route1 = postRequest("plugin-test") { implicit t =>
75+
complete("fine")
76+
}
77+
78+
val route2 = postRequest("payinvoice") { implicit t =>
79+
complete("gets overridden by base API endpoint")
80+
}
81+
82+
route1 ~ route2
83+
}
84+
}
85+
7186
class MockService(eclair: Eclair) extends Service {
7287
override val eclairApi: Eclair = eclair
7388

7489
override def password: String = "mock"
7590

7691
override implicit val actorSystem: ActorSystem = system
77-
override implicit val mat: Materializer = materializer
92+
93+
val route: Route = finalRoutes(Seq(PluginApi.route(this)))
7894
}
7995

8096
def mockApi(eclair: Eclair = mock[Eclair]): MockService = {
@@ -170,6 +186,17 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
170186
}
171187
}
172188

189+
test("plugin injects its own route") {
190+
Post("/plugin-test") ~>
191+
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
192+
Route.seal(mockApi().route) ~>
193+
check {
194+
assert(handled)
195+
assert(status == OK)
196+
assert(entityAs[String] == "fine")
197+
}
198+
}
199+
173200
test("'usablebalances' asks relayer for current usable balances") {
174201
val eclair = mock[Eclair]
175202
eclair.usableBalances()(any[Timeout]) returns Future.successful(List(

0 commit comments

Comments
 (0)