Skip to content

Commit

Permalink
Allow plugins to inject their own routes into API (#1805)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
akumaigorodski authored May 19, 2021
1 parent 76894bd commit 9a20aad
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 24 deletions.
21 changes: 11 additions & 10 deletions eclair-node/src/main/scala/fr/acinq/eclair/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions eclair-node/src/main/scala/fr/acinq/eclair/Plugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
11 changes: 2 additions & 9 deletions eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand All @@ -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)(_ ~ _)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 9a20aad

Please sign in to comment.