diff --git a/akka-http-core/src/main/scala/akka/http/model/Uri.scala b/akka-http-core/src/main/scala/akka/http/model/Uri.scala index 54100256c5..aec9588b62 100644 --- a/akka-http-core/src/main/scala/akka/http/model/Uri.scala +++ b/akka-http-core/src/main/scala/akka/http/model/Uri.scala @@ -405,6 +405,16 @@ object Uri { def isEmpty: Boolean def startsWithSlash: Boolean def startsWithSegment: Boolean + def endsWithSlash: Boolean = { + import Path.{ Empty ⇒ PEmpty, _ } + @tailrec def check(path: Path): Boolean = path match { + case PEmpty ⇒ false + case Slash(PEmpty) ⇒ true + case Slash(tail) ⇒ check(tail) + case Segment(head, tail) ⇒ check(tail) + } + check(this) + } def head: Head def tail: Path def length: Int diff --git a/akka-http-core/src/main/scala/akka/http/model/japi/JavaUri.scala b/akka-http-core/src/main/scala/akka/http/model/japi/JavaUri.scala index 43a770761e..75e302b366 100644 --- a/akka-http-core/src/main/scala/akka/http/model/japi/JavaUri.scala +++ b/akka-http-core/src/main/scala/akka/http/model/japi/JavaUri.scala @@ -73,15 +73,8 @@ protected[model] case class JavaUri(uri: model.Uri) extends Uri { import model.Uri.Path import Path._ - @tailrec def endsWithSlash(path: Path): Boolean = path match { - case Empty ⇒ false - case Slash(Empty) ⇒ true - case Slash(tail) ⇒ endsWithSlash(tail) - case Segment(head, tail) ⇒ endsWithSlash(tail) - } - val newPath = - if (endsWithSlash(u.path)) u.path ++ Path(segment) + if (u.path.endsWithSlash) u.path ++ Path(segment) else u.path ++ Path./(segment) u.withPath(newPath) diff --git a/akka-http-core/src/test/scala/akka/http/model/UriSpec.scala b/akka-http-core/src/test/scala/akka/http/model/UriSpec.scala index 7e4bd23232..f63e190060 100644 --- a/akka-http-core/src/test/scala/akka/http/model/UriSpec.scala +++ b/akka-http-core/src/test/scala/akka/http/model/UriSpec.scala @@ -241,6 +241,15 @@ class UriSpec extends WordSpec with Matchers { Path("/abc/def") startsWith Path("/abc/def") shouldBe true Path("/abc/def") startsWith Path("/abc/def/") shouldBe false } + "support the `endsWithSlash` predicate" in { + Empty.endsWithSlash shouldBe false + Path./.endsWithSlash shouldBe true + Path("abc").endsWithSlash shouldBe false + Path("abc/").endsWithSlash shouldBe true + Path("/abc").endsWithSlash shouldBe false + Path("/abc/def").endsWithSlash shouldBe false + Path("/abc/def/").endsWithSlash shouldBe true + } "support the `dropChars` modifier" in { Path./.dropChars(0) shouldEqual Path./ Path./.dropChars(1) shouldEqual Empty diff --git a/akka-http-tests/src/test/scala/akka/http/server/directives/PathDirectivesSpec.scala b/akka-http-tests/src/test/scala/akka/http/server/directives/PathDirectivesSpec.scala index c532717a11..ebe4be4e10 100644 --- a/akka-http-tests/src/test/scala/akka/http/server/directives/PathDirectivesSpec.scala +++ b/akka-http-tests/src/test/scala/akka/http/server/directives/PathDirectivesSpec.scala @@ -5,8 +5,9 @@ package akka.http.server.directives import akka.http.server._ +import org.scalatest.Inside -class PathDirectivesSpec extends RoutingSpec { +class PathDirectivesSpec extends RoutingSpec with Inside { val echoUnmatchedPath = extractUnmatchedPath { echoComplete } def echoCaptureAndUnmatchedPath[T]: T ⇒ Route = capture ⇒ ctx ⇒ ctx.complete(capture.toString + ":" + ctx.unmatchedPath) @@ -240,4 +241,62 @@ class PathDirectivesSpec extends RoutingSpec { case None ⇒ failTest("Example '" + exampleString + "' doesn't contain a test uri") } } + + import akka.http.model.StatusCodes._ + + "the `redirectToTrailingSlashIfMissing` directive" should { + val route = redirectToTrailingSlashIfMissing(Found) { completeOk } + + "pass if the request path already has a trailing slash" in { + Get("/foo/bar/") ~> route ~> check { response shouldEqual Ok } + } + + "redirect if the request path doesn't have a trailing slash" in { + Get("/foo/bar") ~> route ~> checkRedirectTo("/foo/bar/") + } + + "preserves the query and the frag when redirect" in { + Get("/foo/bar?query#frag") ~> route ~> checkRedirectTo("/foo/bar/?query#frag") + } + + "redirect with the given redirection status code" in { + Get("/foo/bar") ~> + redirectToTrailingSlashIfMissing(MovedPermanently) { completeOk } ~> + check { status shouldEqual MovedPermanently } + } + } + + "the `redirectToNoTrailingSlashIfPresent` directive" should { + val route = redirectToNoTrailingSlashIfPresent(Found) { completeOk } + + "pass if the request path already doesn't have a trailing slash" in { + Get("/foo/bar") ~> route ~> check { response shouldEqual Ok } + } + + "redirect if the request path has a trailing slash" in { + Get("/foo/bar/") ~> route ~> checkRedirectTo("/foo/bar") + } + + "preserves the query and the frag when redirect" in { + Get("/foo/bar/?query#frag") ~> route ~> checkRedirectTo("/foo/bar?query#frag") + } + + "redirect with the given redirection status code" in { + Get("/foo/bar/") ~> + redirectToNoTrailingSlashIfPresent(MovedPermanently) { completeOk } ~> + check { status shouldEqual MovedPermanently } + } + } + + import akka.http.model.headers.Location + import akka.http.model.Uri + + private def checkRedirectTo(expectedUri: Uri) = + check { + status shouldBe a[Redirection] + inside(header[Location]) { + case Some(Location(uri)) ⇒ + (if (expectedUri.isAbsolute) uri else uri.toRelative) shouldEqual expectedUri + } + } } diff --git a/akka-http/src/main/scala/akka/http/server/directives/PathDirectives.scala b/akka-http/src/main/scala/akka/http/server/directives/PathDirectives.scala index d684cada44..fcb0c0e3ed 100644 --- a/akka-http/src/main/scala/akka/http/server/directives/PathDirectives.scala +++ b/akka-http/src/main/scala/akka/http/server/directives/PathDirectives.scala @@ -6,6 +6,8 @@ package akka.http.server package directives import akka.http.common.ToNameReceptacleEnhancements +import akka.http.model.StatusCodes +import akka.http.model.Uri.Path trait PathDirectives extends PathMatchers with ImplicitPathMatcherConstruction with ToNameReceptacleEnhancements { import BasicDirectives._ @@ -100,6 +102,30 @@ trait PathDirectives extends PathMatchers with ImplicitPathMatcherConstruction w /** * Only passes on the request to its inner route if the request path has been matched * completely or only consists of exactly one remaining slash. + * + * Note that trailing slash and non-trailing slash URLs are '''not''' the same, although they often serve + * the same content. It is recommended to serve only one URL version and make the other redirect to it using + * [[redirectToTrailingSlashIfMissing]] or [[redirectToNoTrailingSlashIfPresent]] directive. + * + * For example: + * {{{ + * def route = { + * // redirect '/users/' to '/users', '/users/:userId/' to '/users/:userId' + * redirectToNoTrailingSlashIfPresent(Found) { + * pathPrefix("users") { + * pathEnd { + * // user list ... + * } ~ + * path(UUID) { userId => + * // user profile ... + * } + * } + * } + * } + * }}} + * + * For further information, refer to: + * [[http://googlewebmastercentral.blogspot.de/2010/04/to-slash-or-not-to-slash.html]] */ def pathEndOrSingleSlash: Directive0 = rawPathPrefix(Slash.? ~ PathEnd) @@ -108,6 +134,36 @@ trait PathDirectives extends PathMatchers with ImplicitPathMatcherConstruction w * consists of exactly one remaining slash. */ def pathSingleSlash: Directive0 = pathPrefix(PathEnd) + + /** + * If the request path doesn't end with a slash, redirect to the same uri with trailing slash in the path. + * + * '''Caveat''': [[path]] without trailing slash and [[pathEnd]] directives will not match inside of this directive. + */ + def redirectToTrailingSlashIfMissing(redirectionType: StatusCodes.Redirection): Directive0 = + extractUri.flatMap { uri ⇒ + if (uri.path.endsWithSlash) pass + else { + val newPath = uri.path ++ Path.SingleSlash + val newUri = uri.withPath(newPath) + redirect(newUri, redirectionType) + } + } + + /** + * If the request path ends with a slash, redirect to the same uri without trailing slash in the path. + * + * '''Caveat''': [[pathSingleSlash]] directive will not match inside of this directive. + */ + def redirectToNoTrailingSlashIfPresent(redirectionType: StatusCodes.Redirection): Directive0 = + extractUri.flatMap { uri ⇒ + if (uri.path.endsWithSlash) { + val newPath = uri.path.reverse.tail.reverse + val newUri = uri.withPath(newPath) + redirect(newUri, redirectionType) + } else pass + } + } -object PathDirectives extends PathDirectives \ No newline at end of file +object PathDirectives extends PathDirectives