Skip to content

Commit

Permalink
Refactor and simplify API dsl (#1690)
Browse files Browse the repository at this point in the history
Refactor the API handlers.
Split handlers and directives in several files to make them more composable.

Co-authored-by: Pierre-Marie Padiou <[email protected]>
Co-authored-by: Bastien Teinturier <[email protected]>
  • Loading branch information
3 people authored Mar 9, 2021
1 parent ea8f940 commit 9ff2f83
Show file tree
Hide file tree
Showing 22 changed files with 1,037 additions and 430 deletions.
16 changes: 12 additions & 4 deletions eclair-core/eclair-cli
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ and COMMAND is one of the available commands:
- connect
- disconnect
- peers
- nodes
- audit
=== Channel ===
Expand All @@ -45,6 +44,8 @@ and COMMAND is one of the available commands:
- allchannels
- allupdates
- channelstats
=== Fees ===
- networkfees
- updaterelayfee
Expand All @@ -53,6 +54,7 @@ and COMMAND is one of the available commands:
- findroutetonode
- findroutebetweennodes
- networkstats
- nodes
=== Invoice ===
- createinvoice
Expand All @@ -62,15 +64,21 @@ and COMMAND is one of the available commands:
- parseinvoice
=== Payment ===
- getnewaddress
- usablebalances
- onchainbalance
- payinvoice
- sendtonode
- sendtoroute
- sendonchain
- getsentinfo
- getreceivedinfo
=== Message ===
- signmessage
- verifymessage
=== OnChain ===
- getnewaddress
- sendonchain
- onchainbalance
- onchaintransactions
Examples
Expand Down
366 changes: 26 additions & 340 deletions eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.api.directives

import akka.http.scaladsl.server.Directive0
import akka.http.scaladsl.server.directives.Credentials
import fr.acinq.eclair.api.Service

import scala.concurrent.Future
import scala.concurrent.duration.DurationInt

trait AuthDirective {
this: Service with EclairDirectives =>

/**
* A directive0 that passes whenever valid basic credentials are provided. We
* are not interested in the extracted username.
*
* @return
*/
def authenticated: Directive0 = authenticateBasicAsync(realm = "Access restricted", userPassAuthenticator).tflatMap { _ => pass }


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

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.api.directives

import akka.http.scaladsl.model.HttpMethods.POST
import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`, public}
import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.server.Directive0
import akka.http.scaladsl.server.Directives.respondWithDefaultHeaders

trait DefaultHeaders {

/**
* Adds customHeaders to all http responses.
*/
def eclairHeaders:Directive0 = respondWithDefaultHeaders(customHeaders)


private val customHeaders = `Access-Control-Allow-Headers`("Content-Type, Authorization") ::
`Access-Control-Allow-Methods`(POST) ::
`Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.api.directives

import akka.http.scaladsl.server.{Directive0, Directive1, Directives}
import akka.util.Timeout
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 { this: Service =>

/**
* Prepares inner routes to be exposed as public API with default headers, error handlers and basic authentication.
*/
private def securedHandler:Directive0 = eclairHeaders & handled & authenticated

/**
* Provides a Timeout to the inner route either from request param or the default.
*/
private def standardHandler:Directive1[Timeout] = toStrictEntity(5 seconds) & withTimeout

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

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

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.api.directives

import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpResponse, StatusCodes}
import akka.http.scaladsl.server.{Directive0, ExceptionHandler, RejectionHandler}
import fr.acinq.eclair.api.Service

trait ErrorDirective {
this: Service with EclairDirectives =>

/**
* Handles API exceptions and rejections. Produces json formatted
* error responses.
*/
def handled: Directive0 = handleExceptions(apiExceptionHandler) &
handleRejections(apiRejectionHandler)


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

private val apiExceptionHandler = ExceptionHandler {
case t: IllegalArgumentException =>
logger.error(s"API call failed with cause=${t.getMessage}", t)
complete(StatusCodes.BadRequest, ErrorResponse(t.getMessage))
case t: Throwable =>
logger.error(s"API call failed with cause=${t.getMessage}", t)
complete(StatusCodes.InternalServerError, ErrorResponse(t.getMessage))
}

// map all the rejections to a JSON error object ErrorResponse
private val apiRejectionHandler = RejectionHandler.default.mapRejectionResponse {
case res@HttpResponse(_, _, ent: HttpEntity.Strict, _) =>
res.withEntity(
HttpEntity(ContentTypes.`application/json`, serialization.writePretty(ErrorResponse(ent.data.utf8String)))
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.api.directives

case class ErrorResponse(error: String)
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@
* limitations under the License.
*/

package fr.acinq.eclair.api
package fr.acinq.eclair.api.directives

import fr.acinq.eclair.api.serde.JsonSupport.serialization
import akka.http.scaladsl.common.{NameReceptacle, NameUnmarshallerReceptacle}
import akka.http.scaladsl.marshalling.ToResponseMarshaller
import akka.http.scaladsl.model.StatusCodes.NotFound
import akka.http.scaladsl.model.{ContentTypes, HttpResponse}
import akka.http.scaladsl.server.{Directive1, Directives, MalformedFormFieldRejection, Route}
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.eclair.ApiTypes.ChannelIdentifier
import fr.acinq.eclair.api.FormParamExtractors._
import fr.acinq.eclair.api.JsonSupport._
import fr.acinq.eclair.api.serde.FormParamExtractors._
import fr.acinq.eclair.api.serde.JsonSupport._
import fr.acinq.eclair.payment.PaymentRequest
import fr.acinq.eclair.{MilliSatoshi, ShortChannelId}

Expand All @@ -34,17 +36,17 @@ import scala.util.{Failure, Success}
trait ExtraDirectives extends Directives {

// named and typed URL parameters used across several routes
val shortChannelIdFormParam = "shortChannelId".as[ShortChannelId](shortChannelIdUnmarshaller)
val shortChannelIdsFormParam = "shortChannelIds".as[List[ShortChannelId]](shortChannelIdsUnmarshaller)
val channelIdFormParam = "channelId".as[ByteVector32](sha256HashUnmarshaller)
val channelIdsFormParam = "channelIds".as[List[ByteVector32]](sha256HashesUnmarshaller)
val nodeIdFormParam = "nodeId".as[PublicKey]
val nodeIdsFormParam = "nodeIds".as[List[PublicKey]](pubkeyListUnmarshaller)
val paymentHashFormParam = "paymentHash".as[ByteVector32](sha256HashUnmarshaller)
val fromFormParam = "from".as[Long]
val toFormParam = "to".as[Long]
val amountMsatFormParam = "amountMsat".as[MilliSatoshi]
val invoiceFormParam = "invoice".as[PaymentRequest]
val shortChannelIdFormParam: NameUnmarshallerReceptacle[ShortChannelId] = "shortChannelId".as[ShortChannelId](shortChannelIdUnmarshaller)
val shortChannelIdsFormParam: NameUnmarshallerReceptacle[List[ShortChannelId]] = "shortChannelIds".as[List[ShortChannelId]](shortChannelIdsUnmarshaller)
val channelIdFormParam: NameUnmarshallerReceptacle[ByteVector32] = "channelId".as[ByteVector32](sha256HashUnmarshaller)
val channelIdsFormParam: NameUnmarshallerReceptacle[List[ByteVector32]] = "channelIds".as[List[ByteVector32]](sha256HashesUnmarshaller)
val nodeIdFormParam: NameReceptacle[PublicKey] = "nodeId".as[PublicKey]
val nodeIdsFormParam: NameUnmarshallerReceptacle[List[PublicKey]] = "nodeIds".as[List[PublicKey]](pubkeyListUnmarshaller)
val paymentHashFormParam: NameUnmarshallerReceptacle[ByteVector32] = "paymentHash".as[ByteVector32](sha256HashUnmarshaller)
val fromFormParam: NameReceptacle[Long] = "from".as[Long]
val toFormParam: NameReceptacle[Long] = "to".as[Long]
val amountMsatFormParam: NameReceptacle[MilliSatoshi] = "amountMsat".as[MilliSatoshi]
val invoiceFormParam: NameReceptacle[PaymentRequest] = "invoice".as[PaymentRequest]

// custom directive to fail with HTTP 404 (and JSON response) if the element was not found
def completeOrNotFound[T](fut: Future[Option[T]])(implicit marshaller: ToResponseMarshaller[T]): Route = onComplete(fut) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2019 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.eclair.api.directives

import akka.http.scaladsl.model.{ContentTypes, HttpRequest, HttpResponse, StatusCodes}
import akka.http.scaladsl.server.{Directive0, Directive1, Directives}
import akka.util.Timeout
import fr.acinq.eclair.api.serde.FormParamExtractors._
import fr.acinq.eclair.api.serde.JsonSupport._
import fr.acinq.eclair.api.serde.JsonSupport

import scala.concurrent.duration.DurationInt

trait TimeoutDirective extends Directives {

import JsonSupport.{formats, serialization}


/**
* Extracts a given request timeout from an optional form field. Provides either the
* extracted Timeout or a default Timeout to the inner route.
*/
def withTimeout:Directive1[Timeout] = extractTimeout.tflatMap { timeout =>
withTimeoutRequest(timeout._1) & provide(timeout._1)
}

private val timeoutResponse: HttpRequest => HttpResponse = { _ =>
HttpResponse(StatusCodes.RequestTimeout).withEntity(
ContentTypes.`application/json`, serialization.writePretty(ErrorResponse("request timed out"))
)
}

private def withTimeoutRequest(t: Timeout): Directive0 = withRequestTimeout(t.duration + 2.seconds) &
withRequestTimeoutResponse(timeoutResponse)

private def extractTimeout: Directive1[Timeout] = formField("timeoutSeconds".as[Timeout].?).tflatMap { opt =>
provide(opt._1.getOrElse(Timeout(30 seconds)))
}

}
Loading

0 comments on commit 9ff2f83

Please sign in to comment.