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:
parent
76894bd2e1
commit
9a20aade0a
5 changed files with 52 additions and 24 deletions
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)(_ ~ _)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 =>
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Reference in a new issue