Skip to content

Commit 8dc64db

Browse files
authored
Fix API regression (#1729)
We incorrectly applied error handlers at each sub-route instead of applying it after grouping all sub-routes together. The result was that only `getinfo` could actually be called.
1 parent ded5ce0 commit 8dc64db

14 files changed

+55
-45
lines changed

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

+6-5
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ trait Service extends EclairDirectives with WebSocket with Node with Channel wit
2828

2929
/**
3030
* Allows router access to the API password as configured in eclair.conf
31-
*
32-
* @return
3331
*/
3432
def password: String
3533

@@ -49,9 +47,12 @@ trait Service extends EclairDirectives with WebSocket with Node with Channel wit
4947
implicit val mat: Materializer
5048

5149
/**
52-
* Collect routes from all sub-routers here. This is the main entrypoint for the global
53-
* http request router of the API service.
50+
* Collect routes from all sub-routers here.
51+
* This is the main entrypoint for the global http request router of the API service.
52+
* This is where we handle errors to ensure all routes are correctly tried before rejecting.
5453
*/
55-
val route: Route = nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket
54+
val route: Route = securedHandler {
55+
nodeRoutes ~ channelRoutes ~ feeRoutes ~ pathFindingRoutes ~ invoiceRoutes ~ paymentRoutes ~ messageRoutes ~ onChainRoutes ~ webSocket
56+
}
5657

5758
}

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

-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ trait AuthDirective {
3434
*/
3535
def authenticated: Directive0 = authenticateBasicAsync(realm = "Access restricted", userPassAuthenticator).tflatMap { _ => pass }
3636

37-
3837
private def userPassAuthenticator(credentials: Credentials): Future[Option[String]] = credentials match {
3938
case p@Credentials.Provided(id) if p.verify(password) => Future.successful(Some(id))
4039
case _ => akka.pattern.after(1 second, using = actorSystem.scheduler)(Future.successful(None))(actorSystem.dispatcher) // force a 1 sec pause to deter brute force

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

+3-4
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@ trait DefaultHeaders {
2727
/**
2828
* Adds customHeaders to all http responses.
2929
*/
30-
def eclairHeaders:Directive0 = respondWithDefaultHeaders(customHeaders)
31-
30+
def eclairHeaders: Directive0 = respondWithDefaultHeaders(customHeaders)
3231

3332
private val customHeaders = `Access-Control-Allow-Headers`("Content-Type, Authorization") ::
34-
`Access-Control-Allow-Methods`(POST) ::
35-
`Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil
33+
`Access-Control-Allow-Methods`(POST) ::
34+
`Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil
3635
}

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

+8-6
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,28 @@ 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 { this: Service =>
25+
class EclairDirectives extends Directives with TimeoutDirective with ErrorDirective with AuthDirective with DefaultHeaders with ExtraDirectives {
26+
this: Service =>
2627

2728
/**
28-
* Prepares inner routes to be exposed as public API with default headers, error handlers and basic authentication.
29+
* Prepares inner routes to be exposed as public API with default headers, basic authentication and error handling.
30+
* Must be applied *after* aggregating all the inner routes.
2931
*/
30-
private def securedHandler:Directive0 = eclairHeaders & handled & authenticated
32+
def securedHandler: Directive0 = eclairHeaders & handled & authenticated
3133

3234
/**
3335
* Provides a Timeout to the inner route either from request param or the default.
3436
*/
35-
private def standardHandler:Directive1[Timeout] = toStrictEntity(5 seconds) & withTimeout
37+
private def standardHandler: Directive1[Timeout] = toStrictEntity(5 seconds) & withTimeout
3638

3739
/**
3840
* Handles POST requests with given simple path. The inner route is wrapped in a standard handler and provides a Timeout as parameter.
3941
*/
40-
def postRequest(p:String):Directive1[Timeout] = securedHandler & post & path(p) & standardHandler
42+
def postRequest(p: String): Directive1[Timeout] = standardHandler & post & path(p)
4143

4244
/**
4345
* Handles GET requests with given simple path. The inner route is wrapped in a standard handler and provides a Timeout as parameter.
4446
*/
45-
def getRequest(p:String):Directive1[Timeout] = securedHandler & get & path(p) & standardHandler
47+
def getRequest(p: String): Directive1[Timeout] = standardHandler & get & path(p)
4648

4749
}

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

+3-8
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,9 @@ trait ErrorDirective {
2424
this: Service with EclairDirectives =>
2525

2626
/**
27-
* Handles API exceptions and rejections. Produces json formatted
28-
* error responses.
27+
* Handles API exceptions and rejections. Produces json formatted error responses.
2928
*/
30-
def handled: Directive0 = handleExceptions(apiExceptionHandler) &
31-
handleRejections(apiRejectionHandler)
32-
29+
def handled: Directive0 = handleExceptions(apiExceptionHandler) & handleRejections(apiRejectionHandler)
3330

3431
import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization}
3532

@@ -45,9 +42,7 @@ trait ErrorDirective {
4542
// map all the rejections to a JSON error object ErrorResponse
4643
private val apiRejectionHandler = RejectionHandler.default.mapRejectionResponse {
4744
case res@HttpResponse(_, _, ent: HttpEntity.Strict, _) =>
48-
res.withEntity(
49-
HttpEntity(ContentTypes.`application/json`, serialization.writePretty(ErrorResponse(ent.data.utf8String)))
50-
)
45+
res.withEntity(HttpEntity(ContentTypes.`application/json`, serialization.writePretty(ErrorResponse(ent.data.utf8String))))
5146
}
5247

5348
}

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

-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package fr.acinq.eclair.api.directives
1818

19-
import fr.acinq.eclair.api.serde.JsonSupport.serialization
2019
import akka.http.scaladsl.common.{NameReceptacle, NameUnmarshallerReceptacle}
2120
import akka.http.scaladsl.marshalling.ToResponseMarshaller
2221
import akka.http.scaladsl.model.StatusCodes.NotFound

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

+2-3
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,20 @@ import akka.http.scaladsl.model.{ContentTypes, HttpRequest, HttpResponse, Status
2020
import akka.http.scaladsl.server.{Directive0, Directive1, Directives}
2121
import akka.util.Timeout
2222
import fr.acinq.eclair.api.serde.FormParamExtractors._
23-
import fr.acinq.eclair.api.serde.JsonSupport._
2423
import fr.acinq.eclair.api.serde.JsonSupport
24+
import fr.acinq.eclair.api.serde.JsonSupport._
2525

2626
import scala.concurrent.duration.DurationInt
2727

2828
trait TimeoutDirective extends Directives {
2929

3030
import JsonSupport.{formats, serialization}
3131

32-
3332
/**
3433
* Extracts a given request timeout from an optional form field. Provides either the
3534
* extracted Timeout or a default Timeout to the inner route.
3635
*/
37-
def withTimeout:Directive1[Timeout] = extractTimeout.tflatMap { timeout =>
36+
def withTimeout: Directive1[Timeout] = extractTimeout.tflatMap { timeout =>
3837
withTimeoutRequest(timeout._1) & provide(timeout._1)
3938
}
4039

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Channel.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route}
2020
import akka.util.Timeout
2121
import fr.acinq.bitcoin.Satoshi
2222
import fr.acinq.eclair.MilliSatoshi
23-
import fr.acinq.eclair.api.serde.FormParamExtractors._
2423
import fr.acinq.eclair.api.Service
2524
import fr.acinq.eclair.api.directives.EclairDirectives
25+
import fr.acinq.eclair.api.serde.FormParamExtractors._
2626
import fr.acinq.eclair.blockchain.fee.FeeratePerByte
2727
import scodec.bits.ByteVector
2828

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Invoice.scala

+4-5
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,11 @@
1616

1717
package fr.acinq.eclair.api.handlers
1818

19-
import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route}
20-
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
21-
import fr.acinq.eclair.api.serde.FormParamExtractors._
19+
import akka.http.scaladsl.server.Route
20+
import fr.acinq.bitcoin.ByteVector32
2221
import fr.acinq.eclair.api.Service
2322
import fr.acinq.eclair.api.directives.EclairDirectives
24-
import fr.acinq.eclair.payment.PaymentRequest
23+
import fr.acinq.eclair.api.serde.FormParamExtractors._
2524

2625
trait Invoice {
2726
this: Service with EclairDirectives =>
@@ -60,6 +59,6 @@ trait Invoice {
6059
}
6160
}
6261

63-
val invoiceRoutes: Route = createInvoice ~ getInvoice ~ listInvoices ~ listPendingInvoices ~ parseInvoice
62+
val invoiceRoutes: Route = createInvoice ~ getInvoice ~ listInvoices ~ listPendingInvoices ~ parseInvoice
6463

6564
}

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Message.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
package fr.acinq.eclair.api.handlers
1818

1919
import akka.http.scaladsl.server.Route
20-
import fr.acinq.eclair.api.serde.FormParamExtractors._
2120
import fr.acinq.eclair.api.Service
2221
import fr.acinq.eclair.api.directives.EclairDirectives
22+
import fr.acinq.eclair.api.serde.FormParamExtractors._
2323
import scodec.bits.ByteVector
2424

2525
trait Message {

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Node.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import akka.http.scaladsl.server.Route
2020
import com.google.common.net.HostAndPort
2121
import fr.acinq.eclair.api.Service
2222
import fr.acinq.eclair.api.directives.EclairDirectives
23-
import fr.acinq.eclair.io.NodeURI
2423
import fr.acinq.eclair.api.serde.FormParamExtractors._
24+
import fr.acinq.eclair.io.NodeURI
2525

2626
trait Node {
2727
this: Service with EclairDirectives =>

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/Payment.scala

+1-2
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,12 @@ package fr.acinq.eclair.api.handlers
1919
import akka.http.scaladsl.server.{MalformedFormFieldRejection, Route}
2020
import fr.acinq.bitcoin.Crypto.PublicKey
2121
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
22-
import fr.acinq.eclair.api.serde.FormParamExtractors.pubkeyListUnmarshaller
2322
import fr.acinq.eclair.api.Service
2423
import fr.acinq.eclair.api.directives.EclairDirectives
24+
import fr.acinq.eclair.api.serde.FormParamExtractors.{pubkeyListUnmarshaller, _}
2525
import fr.acinq.eclair.payment.PaymentRequest
2626
import fr.acinq.eclair.router.Router.{PredefinedChannelRoute, PredefinedNodeRoute}
2727
import fr.acinq.eclair.{CltvExpiryDelta, MilliSatoshi}
28-
import fr.acinq.eclair.api.serde.FormParamExtractors._
2928

3029
import java.util.UUID
3130

eclair-node/src/main/scala/fr/acinq/eclair/api/handlers/WebSocket.scala

-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ trait WebSocket {
3838
handleWebSocketMessages(makeSocketHandler)
3939
}
4040

41-
4241
// Init the websocket message flow
4342
private lazy val makeSocketHandler: Flow[Message, TextMessage.Strict, NotUsed] = {
4443

@@ -74,5 +73,4 @@ trait WebSocket {
7473
.map(TextMessage.apply)
7574
}
7675

77-
7876
}

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

+25-5
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
7676
override implicit val mat: Materializer = materializer
7777
}
7878

79-
def mockApi(eclair:Eclair = mock[Eclair]): MockService = {
79+
def mockApi(eclair: Eclair = mock[Eclair]): MockService = {
8080
new MockService(eclair)
8181
}
8282

@@ -122,7 +122,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
122122
test("API returns invalid channelId on invalid channelId form data") {
123123
Post("/channel", FormData(Map("channelId" -> "hey")).toEntity) ~>
124124
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
125-
Route.seal(mockApi().channel) ~>
125+
Route.seal(mockApi().route) ~>
126126
check {
127127
assert(handled)
128128
assert(status == BadRequest)
@@ -247,6 +247,17 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
247247
assert(entityAs[String] == "\"created channel 56d7d6eda04d80138270c49709f1eadb5ab4939e5061309ccdacdb98ce637d0e\"")
248248
eclair.open(nodeId, 50000 sat, None, None, Some(100 msat, 10), None, None)(any[Timeout]).wasCalled(once)
249249
}
250+
251+
Post("/open", FormData("nodeId" -> nodeId.toString(), "fundingSatoshis" -> "25000", "feeBaseMsat" -> "250", "feeProportionalMillionths" -> "10").toEntity) ~>
252+
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
253+
addHeader("Content-Type", "application/json") ~>
254+
Route.seal(mockService.route) ~>
255+
check {
256+
assert(handled)
257+
assert(status == OK)
258+
assert(entityAs[String] == "\"created channel 56d7d6eda04d80138270c49709f1eadb5ab4939e5061309ccdacdb98ce637d0e\"")
259+
eclair.open(nodeId, 25000 sat, None, None, Some(250 msat, 10), None, None)(any[Timeout]).wasCalled(once)
260+
}
250261
}
251262

252263
test("'close' method should accept channelIds and shortChannelIds") {
@@ -338,7 +349,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
338349

339350
Post("/payinvoice", FormData("invoice" -> invoice).toEntity) ~>
340351
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
341-
Route.seal(mockService.payInvoice) ~>
352+
Route.seal(mockService.route) ~>
342353
check {
343354
assert(handled)
344355
assert(status == BadRequest)
@@ -372,6 +383,15 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
372383
assert(status == OK)
373384
eclair.send(Some("42"), any, 123 msat, any, any, any, Some(112233 sat), Some(2.34))(any[Timeout]).wasCalled(once)
374385
}
386+
387+
Post("/payinvoice", FormData("invoice" -> invoice, "amountMsat" -> "456", "feeThresholdSat" -> "10", "maxFeePct" -> "0.5").toEntity) ~>
388+
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
389+
Route.seal(mockService.route) ~>
390+
check {
391+
assert(handled)
392+
assert(status == OK)
393+
eclair.send(None, any, 456 msat, any, any, any, Some(10 sat), Some(0.5))(any[Timeout]).wasCalled(once)
394+
}
375395
}
376396

377397
test("'getreceivedinfo'") {
@@ -412,7 +432,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
412432

413433
Post("/getreceivedinfo", FormData("paymentHash" -> expired.toHex).toEntity) ~>
414434
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
415-
Route.seal(mockService.getReceivedInfo) ~>
435+
Route.seal(mockService.route) ~>
416436
check {
417437
assert(handled)
418438
assert(status == OK)
@@ -457,7 +477,7 @@ class ApiServiceSpec extends AnyFunSuite with ScalatestRouteTest with IdiomaticM
457477

458478
Post("/getsentinfo", FormData("id" -> failed.toString).toEntity) ~>
459479
addCredentials(BasicHttpCredentials("", mockApi().password)) ~>
460-
Route.seal(mockService.getSentInfo) ~>
480+
Route.seal(mockService.route) ~>
461481
check {
462482
assert(handled)
463483
assert(status == OK)

0 commit comments

Comments
 (0)