diff --git a/akka-docs-dev/rst/scala/code/docs/http/server/RejectionHandlerExamplesSpec.scala b/akka-docs-dev/rst/scala/code/docs/http/server/RejectionHandlerExamplesSpec.scala index 9a001785d2..43c0b2c2b1 100644 --- a/akka-docs-dev/rst/scala/code/docs/http/server/RejectionHandlerExamplesSpec.scala +++ b/akka-docs-dev/rst/scala/code/docs/http/server/RejectionHandlerExamplesSpec.scala @@ -18,10 +18,12 @@ object MyRejectionHandler { import StatusCodes._ import Directives._ - implicit val myRejectionHandler = RejectionHandler { - case MissingCookieRejection(cookieName) :: _ => - complete(HttpResponse(BadRequest, entity = "No cookies, no service!!!")) - } + implicit val myRejectionHandler = RejectionHandler.newBuilder() + .handle { + case MissingCookieRejection(cookieName) => + complete(HttpResponse(BadRequest, entity = "No cookies, no service!!!")) + } + .result() object MyApp { implicit val system = ActorSystem() diff --git a/akka-docs-dev/rst/scala/code/docs/http/server/directives/ExecutionDirectivesExamplesSpec.scala b/akka-docs-dev/rst/scala/code/docs/http/server/directives/ExecutionDirectivesExamplesSpec.scala index 3c9883de0e..3d72a37f1b 100644 --- a/akka-docs-dev/rst/scala/code/docs/http/server/directives/ExecutionDirectivesExamplesSpec.scala +++ b/akka-docs-dev/rst/scala/code/docs/http/server/directives/ExecutionDirectivesExamplesSpec.scala @@ -29,10 +29,9 @@ class ExecutionDirectivesExamplesSpec extends RoutingSpec { } } "handleRejections" in { - val totallyMissingHandler = RejectionHandler { - case Nil /* secret code for path not found */ => - complete(StatusCodes.NotFound, "Oh man, what you are looking for is long gone.") - } + val totallyMissingHandler = RejectionHandler.newBuilder() + .handleNotFound { complete(StatusCodes.NotFound, "Oh man, what you are looking for is long gone.") } + .result() val route = pathPrefix("handled") { handleRejections(totallyMissingHandler) { diff --git a/akka-http-tests/src/test/scala/akka/http/server/BasicRouteSpecs.scala b/akka-http-tests/src/test/scala/akka/http/server/BasicRouteSpecs.scala index e129a502d0..1ef6c0ada8 100644 --- a/akka-http-tests/src/test/scala/akka/http/server/BasicRouteSpecs.scala +++ b/akka-http-tests/src/test/scala/akka/http/server/BasicRouteSpecs.scala @@ -163,5 +163,16 @@ class BasicRouteSpecs extends RoutingSpec { status shouldEqual StatusCodes.InternalServerError } } + "always prioritize MethodRejections over AuthorizationFailedRejections" in { + Get("/abc") ~> Route.seal { + post { completeOk } ~ + authorize(false) { completeOk } + } ~> check { status shouldEqual StatusCodes.MethodNotAllowed } + + Get("/abc") ~> Route.seal { + authorize(false) { completeOk } ~ + post { completeOk } + } ~> check { status shouldEqual StatusCodes.MethodNotAllowed } + } } } diff --git a/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala b/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala index d72952dcef..c7f74f3849 100644 --- a/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala +++ b/akka-http/src/main/scala/akka/http/server/RejectionHandler.scala @@ -4,6 +4,8 @@ package akka.http.server +import scala.annotation.tailrec +import scala.reflect.ClassTag import scala.collection.immutable import scala.concurrent.ExecutionContext import akka.http.model._ @@ -12,114 +14,203 @@ import headers._ import directives.RouteDirectives._ import AuthenticationFailedRejection._ -trait RejectionHandler extends RejectionHandler.PF { - def isDefault: Boolean +trait RejectionHandler extends (immutable.Seq[Rejection] ⇒ Option[Route]) { self ⇒ + import RejectionHandler._ + + /** + * Creates a new [[RejectionHandler]] which uses the given one as fallback for this one. + */ + def withFallback(that: RejectionHandler): RejectionHandler = + (this, that) match { + case (a: BuiltRejectionHandler, b: BuiltRejectionHandler) ⇒ + new BuiltRejectionHandler(a.cases ++ b.cases, a.notFound orElse b.notFound, a.isSealed || b.isSealed) + case _ ⇒ new RejectionHandler { + def apply(rejections: immutable.Seq[Rejection]): Option[Route] = + self(rejections) orElse that(rejections) + } + } + + /** + * "Seals" this handler by attaching a default handler as fallback if necessary. + */ + def seal(implicit ec: ExecutionContext): RejectionHandler = + this match { + case x: BuiltRejectionHandler if x.isSealed ⇒ x + case _ ⇒ withFallback(default) + } } object RejectionHandler { - type PF = PartialFunction[immutable.Seq[Rejection], Route] - implicit def apply(pf: PF): RejectionHandler = apply(default = false)(pf) + /** + * Creates a new [[RejectionHandler]] builder. + */ + def newBuilder(): Builder = new Builder - private def apply(default: Boolean)(pf: PF): RejectionHandler = - new RejectionHandler { - def isDefault = default - def isDefinedAt(rejections: immutable.Seq[Rejection]) = pf.isDefinedAt(rejections) - def apply(rejections: immutable.Seq[Rejection]) = pf(rejections) + final class Builder { + private[this] val cases = new immutable.VectorBuilder[Handler] + private[this] var notFound: Option[Route] = None + private[this] var hasCatchAll: Boolean = false + + /** + * Handles a single [[Rejection]] with the given partial function. + */ + def handle(pf: PartialFunction[Rejection, Route]): this.type = { + cases += CaseHandler(pf) + hasCatchAll ||= pf.isDefinedAt(PrivateRejection) + this } - def default(implicit ec: ExecutionContext) = apply(default = true) { - case Nil ⇒ complete(NotFound, "The requested resource could not be found.") + /** + * Handles several Rejections of the same type at the same time. + * The seq passed to the given function is guaranteed to be non-empty. + */ + def handleAll[T <: Rejection: ClassTag](f: immutable.Seq[T] ⇒ Route): this.type = { + val runtimeClass = implicitly[ClassTag[T]].runtimeClass + cases += TypeHandler[T](runtimeClass, f) + hasCatchAll ||= runtimeClass == classOf[Rejection] + this + } - case rejections @ (AuthenticationFailedRejection(cause, _) +: _) ⇒ - val rejectionMessage = cause match { - case CredentialsMissing ⇒ "The resource requires authentication, which was not supplied with the request" - case CredentialsRejected ⇒ "The supplied authentication is invalid" - } - val challenges = rejections.collect { case AuthenticationFailedRejection(_, challenge) ⇒ challenge } - // Multiple challenges per WWW-Authenticate header are allowed per spec, - // however, it seems many browsers will ignore all challenges but the first. - // Therefore, multiple WWW-Authenticate headers are rendered, instead. - // - // See https://code.google.com/p/chromium/issues/detail?id=103220 - // and https://bugzilla.mozilla.org/show_bug.cgi?id=669675 - val authenticateHeaders = challenges.map(`WWW-Authenticate`(_)) + /** + * Handles the special "not found" case using the given [[Route]]. + */ + def handleNotFound(route: Route): this.type = { + notFound = Some(route) + this + } - complete(Unauthorized, authenticateHeaders, rejectionMessage) - - case AuthorizationFailedRejection +: _ ⇒ - complete(Forbidden, "The supplied authentication is not authorized to access this resource") - - case MalformedFormFieldRejection(name, msg, _) +: _ ⇒ - complete(BadRequest, "The form field '" + name + "' was malformed:\n" + msg) - - case MalformedHeaderRejection(headerName, msg, _) +: _ ⇒ - complete(BadRequest, s"The value of HTTP header '$headerName' was malformed:\n" + msg) - - case MalformedQueryParamRejection(name, msg, _) +: _ ⇒ - complete(BadRequest, "The query parameter '" + name + "' was malformed:\n" + msg) - - case MalformedRequestContentRejection(msg, _) +: _ ⇒ - complete(BadRequest, "The request content was malformed:\n" + msg) - - case rejections @ (MethodRejection(_) +: _) ⇒ - val methods = rejections.collect { case MethodRejection(method) ⇒ method } - complete(MethodNotAllowed, List(Allow(methods)), "HTTP method not allowed, supported methods: " + methods.mkString(", ")) - - case rejections @ (SchemeRejection(_) +: _) ⇒ - val schemes = rejections.collect { case SchemeRejection(scheme) ⇒ scheme } - complete(BadRequest, "Uri scheme not allowed, supported schemes: " + schemes.mkString(", ")) - - case MissingCookieRejection(cookieName) +: _ ⇒ - complete(BadRequest, "Request is missing required cookie '" + cookieName + '\'') - - case MissingFormFieldRejection(fieldName) +: _ ⇒ - complete(BadRequest, "Request is missing required form field '" + fieldName + '\'') - - case MissingHeaderRejection(headerName) +: _ ⇒ - complete(BadRequest, "Request is missing required HTTP header '" + headerName + '\'') - - case MissingQueryParamRejection(paramName) +: _ ⇒ - complete(NotFound, "Request is missing required query parameter '" + paramName + '\'') - - case RequestEntityExpectedRejection +: _ ⇒ - complete(BadRequest, "Request entity expected but not supplied") - - case TooManyRangesRejection(_) +: _ ⇒ - complete(RequestedRangeNotSatisfiable, "Request contains too many ranges.") - - case UnsatisfiableRangeRejection(unsatisfiableRanges, actualEntityLength) +: _ ⇒ - complete(RequestedRangeNotSatisfiable, List(`Content-Range`(ContentRange.Unsatisfiable(actualEntityLength))), - unsatisfiableRanges.mkString("None of the following requested Ranges were satisfiable:\n", "\n", "")) - - case rejections @ (UnacceptedResponseContentTypeRejection(_) +: _) ⇒ - val supported = rejections.flatMap { - case UnacceptedResponseContentTypeRejection(x) ⇒ x - case _ ⇒ Nil - } - complete(NotAcceptable, "Resource representation is only available with these Content-Types:\n" + supported.map(_.value).mkString("\n")) - - case rejections @ (UnacceptedResponseEncodingRejection(_) +: _) ⇒ - val supported = rejections.flatMap { - case UnacceptedResponseEncodingRejection(x) ⇒ x - case _ ⇒ Nil - } - complete(NotAcceptable, "Resource representation is only available with these Content-Encodings:\n" + supported.map(_.value).mkString("\n")) - - case rejections @ (UnsupportedRequestContentTypeRejection(_) +: _) ⇒ - val supported = rejections.collect { case UnsupportedRequestContentTypeRejection(x) ⇒ x } - complete(UnsupportedMediaType, "The request's Content-Type is not supported. Expected:\n" + supported.mkString(" or ")) - - case rejections @ (UnsupportedRequestEncodingRejection(_) +: _) ⇒ - val supported = rejections.collect { case UnsupportedRequestEncodingRejection(x) ⇒ x } - complete(BadRequest, "The request's Content-Encoding is not supported. Expected:\n" + supported.map(_.value).mkString(" or ")) - - case ValidationRejection(msg, _) +: _ ⇒ - complete(BadRequest, msg) - - case x +: _ ⇒ sys.error("Unhandled rejection: " + x) + def result(): RejectionHandler = + new BuiltRejectionHandler(cases.result(), notFound, hasCatchAll && notFound.isDefined) } + private sealed abstract class Handler + private final case class CaseHandler(pf: PartialFunction[Rejection, Route]) extends Handler + private final case class TypeHandler[T <: Rejection]( + runtimeClass: Class[_], f: immutable.Seq[T] ⇒ Route) extends Handler with PartialFunction[Rejection, T] { + def isDefinedAt(rejection: Rejection) = runtimeClass isInstance rejection + def apply(rejection: Rejection) = rejection.asInstanceOf[T] + } + + private class BuiltRejectionHandler(val cases: Vector[Handler], + val notFound: Option[Route], + val isSealed: Boolean) extends RejectionHandler { + def apply(rejections: immutable.Seq[Rejection]): Option[Route] = + if (rejections.nonEmpty) { + @tailrec def rec(ix: Int): Option[Route] = + if (ix < cases.length) { + cases(ix) match { + case CaseHandler(pf) ⇒ + val route = rejections collectFirst pf + if (route.isEmpty) rec(ix + 1) else route + case x @ TypeHandler(_, f) ⇒ + val rejs = rejections collect x + if (rejs.isEmpty) rec(ix + 1) else Some(f(rejs)) + } + } else None + rec(0) + } else notFound + } + + /** + * Creates a new default [[RejectionHandler]] instance. + */ + def default(implicit ec: ExecutionContext) = + newBuilder() + .handleAll[SchemeRejection] { rejections ⇒ + val schemes = rejections.map(_.supported).mkString(", ") + complete(BadRequest, "Uri scheme not allowed, supported schemes: " + schemes) + } + .handleAll[MethodRejection] { rejections ⇒ + val methods = rejections.map(_.supported) + complete(MethodNotAllowed, List(Allow(methods)), "HTTP method not allowed, supported methods: " + methods.mkString(", ")) + } + .handle { + case AuthorizationFailedRejection ⇒ + complete(Forbidden, "The supplied authentication is not authorized to access this resource") + } + .handle { + case MalformedFormFieldRejection(name, msg, _) ⇒ + complete(BadRequest, "The form field '" + name + "' was malformed:\n" + msg) + } + .handle { + case MalformedHeaderRejection(headerName, msg, _) ⇒ + complete(BadRequest, s"The value of HTTP header '$headerName' was malformed:\n" + msg) + } + .handle { + case MalformedQueryParamRejection(name, msg, _) ⇒ + complete(BadRequest, "The query parameter '" + name + "' was malformed:\n" + msg) + } + .handle { + case MalformedRequestContentRejection(msg, _) ⇒ + complete(BadRequest, "The request content was malformed:\n" + msg) + } + .handle { + case MissingCookieRejection(cookieName) ⇒ + complete(BadRequest, "Request is missing required cookie '" + cookieName + '\'') + } + .handle { + case MissingFormFieldRejection(fieldName) ⇒ + complete(BadRequest, "Request is missing required form field '" + fieldName + '\'') + } + .handle { + case MissingHeaderRejection(headerName) ⇒ + complete(BadRequest, "Request is missing required HTTP header '" + headerName + '\'') + } + .handle { + case MissingQueryParamRejection(paramName) ⇒ + complete(NotFound, "Request is missing required query parameter '" + paramName + '\'') + } + .handle { + case RequestEntityExpectedRejection ⇒ + complete(BadRequest, "Request entity expected but not supplied") + } + .handle { + case TooManyRangesRejection(_) ⇒ + complete(RequestedRangeNotSatisfiable, "Request contains too many ranges.") + } + .handle { + case UnsatisfiableRangeRejection(unsatisfiableRanges, actualEntityLength) ⇒ + complete(RequestedRangeNotSatisfiable, List(`Content-Range`(ContentRange.Unsatisfiable(actualEntityLength))), + unsatisfiableRanges.mkString("None of the following requested Ranges were satisfiable:\n", "\n", "")) + } + .handleAll[AuthenticationFailedRejection] { rejections ⇒ + val rejectionMessage = rejections.head.cause match { + case CredentialsMissing ⇒ "The resource requires authentication, which was not supplied with the request" + case CredentialsRejected ⇒ "The supplied authentication is invalid" + } + // Multiple challenges per WWW-Authenticate header are allowed per spec, + // however, it seems many browsers will ignore all challenges but the first. + // Therefore, multiple WWW-Authenticate headers are rendered, instead. + // + // See https://code.google.com/p/chromium/issues/detail?id=103220 + // and https://bugzilla.mozilla.org/show_bug.cgi?id=669675 + val authenticateHeaders = rejections.map(r ⇒ `WWW-Authenticate`(r.challenge)) + complete(Unauthorized, authenticateHeaders, rejectionMessage) + } + .handleAll[UnacceptedResponseContentTypeRejection] { rejections ⇒ + val supported = rejections.flatMap(_.supported) + complete(NotAcceptable, "Resource representation is only available with these Content-Types:\n" + + supported.map(_.value).mkString("\n")) + } + .handleAll[UnacceptedResponseEncodingRejection] { rejections ⇒ + val supported = rejections.flatMap(_.supported) + complete(NotAcceptable, "Resource representation is only available with these Content-Encodings:\n" + + supported.map(_.value).mkString("\n")) + } + .handleAll[UnsupportedRequestContentTypeRejection] { rejections ⇒ + val supported = rejections.flatMap(_.supported).mkString(" or ") + complete(UnsupportedMediaType, "The request's Content-Type is not supported. Expected:\n" + supported) + } + .handleAll[UnsupportedRequestEncodingRejection] { rejections ⇒ + val supported = rejections.map(_.supported.value).mkString(" or ") + complete(BadRequest, "The request's Content-Encoding is not supported. Expected:\n" + supported) + } + .handle { case ValidationRejection(msg, _) ⇒ complete(BadRequest, msg) } + .handle { case x ⇒ sys.error("Unhandled rejection: " + x) } + .handleNotFound { complete(NotFound, "The requested resource could not be found.") } + .result() + /** * Filters out all TransformationRejections from the given sequence and applies them (in order) to the * remaining rejections. @@ -130,4 +221,6 @@ object RejectionHandler { case (remaining, transformation) ⇒ transformation.transform(remaining) } } + + private object PrivateRejection extends Rejection } diff --git a/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala b/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala index 1fb6dd39b6..d0a9a85627 100644 --- a/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala +++ b/akka-http/src/main/scala/akka/http/server/RequestContextImpl.scala @@ -40,7 +40,7 @@ private[http] class RequestContextImpl( }(executionContext) override def reject(rejections: Rejection*): Future[RouteResult] = - FastFuture.successful(RouteResult.Rejected(rejections.toVector)) + FastFuture.successful(RouteResult.Rejected(rejections.toList)) override def fail(error: Throwable): Future[RouteResult] = FastFuture.failed(error) diff --git a/akka-http/src/main/scala/akka/http/server/Route.scala b/akka-http/src/main/scala/akka/http/server/Route.scala index 7dcffed529..2a155a6870 100644 --- a/akka-http/src/main/scala/akka/http/server/Route.scala +++ b/akka-http/src/main/scala/akka/http/server/Route.scala @@ -25,11 +25,8 @@ object Route { val sealedExceptionHandler = if (exceptionHandler.isDefault) exceptionHandler else exceptionHandler orElse ExceptionHandler.default(settings) - val sealedRejectionHandler = - if (rejectionHandler.isDefault) rejectionHandler - else rejectionHandler orElse RejectionHandler.default handleExceptions(sealedExceptionHandler) { - handleRejections(sealedRejectionHandler) { + handleRejections(rejectionHandler.seal) { route } } diff --git a/akka-http/src/main/scala/akka/http/server/directives/ExecutionDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/ExecutionDirectives.scala index 2a06b5fe14..3d1c8751cc 100644 --- a/akka-http/src/main/scala/akka/http/server/directives/ExecutionDirectives.scala +++ b/akka-http/src/main/scala/akka/http/server/directives/ExecutionDirectives.scala @@ -5,11 +5,10 @@ package akka.http.server package directives -import akka.http.util.FastFuture -import FastFuture._ - import scala.concurrent.Future import scala.util.control.NonFatal +import akka.http.util.FastFuture +import FastFuture._ trait ExecutionDirectives { import BasicDirectives._ @@ -38,12 +37,12 @@ trait ExecutionDirectives { extractRequestContext flatMap { ctx ⇒ recoverRejectionsWith { rejections ⇒ val filteredRejections = RejectionHandler.applyTransformations(rejections) - if (handler isDefinedAt filteredRejections) { - val errorMsg = "The RejectionHandler for %s must not itself produce rejections (received %s)!" - recoverRejections(r ⇒ sys.error(errorMsg.format(filteredRejections, r))) { - handler(filteredRejections) - }(ctx.withAcceptAll) - } else FastFuture.successful(RouteResult.Rejected(filteredRejections)) + handler(filteredRejections) match { + case Some(route) ⇒ + val errorMsg = "The RejectionHandler for %s must not itself produce rejections (received %s)!" + recoverRejections(r ⇒ sys.error(errorMsg.format(filteredRejections, r)))(route)(ctx.withAcceptAll) + case None ⇒ FastFuture.successful(RouteResult.Rejected(filteredRejections)) + } } } }