Permalink
Browse files

! can: Implement redirection following (issue #132)

  • Loading branch information...
Ian Forsey
Ian Forsey committed Oct 6, 2013
1 parent 225238c commit 98365ff0cc3fa7448bd4e0f59d80804c9ed48445
@@ -137,3 +137,41 @@ is to simply bring one into scope implicitly:
.. includecode:: ../code/docs/HttpServerExamplesSpec.scala
:snippet: sslcontext-provision
+
+Redirection Following
+---------------------
+
+Automatic redirection following for 3xx responses is supported by setting the ``spray.can.host-connector.max-redirects``
+as follows:
+
+ - If set to zero redirection responses will not be followed, i.e. they'll be returned to the user as is.
+ - If set to a value > zero redirection responses will be followed up to the given number of times.
+ - If the redirection chain is longer than the configured value the first redirection response that is
+ is not followed anymore is returned to the user as is.
+
+By default ``max-redirects`` is set to 0.
+
+Since this setting is at the host level, it is possible to configure a different number of ``max-redirects`` for
+different hosts (see :ref:`RequestLevelApi`). In this situation the ``max-redirects`` configured for the host of the
+initial request is respected for the entire redirection chain. This is true even if redirection means changing to another
+host.
+
+Which redirects are followed?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This table shows which http method is used to follow redirects for given request methods and response status codes. Any
+Request method and response status code combination not in the table will not result in redirection following and the
+response will be returned as is.
+
+.. rst-class:: table table-striped
+
+======================== ======================= ========================== ==============
+Request Method Response Status Code Redirection Method Specification
+======================== ======================= ========================== ==============
+GET / HEAD 301 / 302 / 303 / 307 Original request method `RFC 2616`_
+Any (except GET / HEAD) 302 / 303 GET `RFC 2616`_
+Any 308 Original request method `Draft Spec`_
+======================== ======================= ========================== ==============
+
+.. _RFC 2616: http://tools.ietf.org/html/rfc2616#section-10.3
+.. _Draft Spec: http://tools.ietf.org/html/draft-reschke-http-status-308-07#section-3
@@ -17,7 +17,7 @@
package spray.can.client
import org.specs2.mutable.Specification
-import com.typesafe.config.{ ConfigFactory, Config }
+import com.typesafe.config.{ ConfigValueFactory, ConfigFactory, Config }
import akka.actor.{ ActorRef, Status, ActorSystem }
import akka.io.IO
import akka.testkit.TestProbe
@@ -28,6 +28,7 @@ import spray.util.Utils._
import spray.httpx.RequestBuilding._
import spray.http._
import HttpHeaders._
+import StatusCodes._
class SprayCanClientSpec extends Specification {
@@ -40,7 +41,8 @@ class SprayCanClientSpec extends Specification {
spray.can.host-connector.idle-timeout = infinite
spray.can.host-connector.client.request-timeout = 500ms
spray.can.server.request-chunk-aggregation-limit = 0
- spray.can.client.response-chunk-aggregation-limit = 0""")
+ spray.can.client.response-chunk-aggregation-limit = 0
+ spray.can.server.transparent-head-requests = off""")
implicit val system = ActorSystem(actorSystemNameFrom(getClass), testConf)
"The connection-level client infrastructure" should {
@@ -231,6 +233,151 @@ class SprayCanClientSpec extends Specification {
hostConnector1 === hostConnector2
hostConnector2 !== hostConnector3
}
+
+ "returns 3xx HttpResponse when follow-redirects is disabled" in new TestSetup {
+ val request = Get("/def") ~> Host(hostname, port)
+ val probe = TestProbe()
+ probe.send(IO(Http), Http.HostConnectorSetup(hostname, port))
+ val Http.HostConnectorInfo(hostConnector, _) = probe.expectMsgType[Http.HostConnectorInfo]
+ probe.reply(request)
+
+ val serverHandler = acceptConnection()
+ serverHandler.expectMsgType[HttpRequest]
+ serverHandler.reply(HttpResponse(status = TemporaryRedirect, headers = Location("/go-here") :: Nil))
+
+ val r = probe.expectMsgType[HttpResponse]
+ r.status === TemporaryRedirect
+ r.header[Location].head.value === "/go-here"
+
+ closeHostConnector(hostConnector)
+ }
+
+ "perform a redirect when max-redirects is > 0" in new TestSetup {
+ val redirectConf = system.settings.config withValue
+ ("spray.can.host-connector.max-redirects", ConfigValueFactory.fromAnyRef(5))
+
+ val request = Get("/def") ~> Host(hostname, port)
+ val probe = TestProbe()
+ probe.send(IO(Http), Http.HostConnectorSetup(hostname, port, settings = Some(HostConnectorSettings(redirectConf))))
+ val Http.HostConnectorInfo(hostConnector, _) = probe.expectMsgType[Http.HostConnectorInfo]
+ probe.reply(request)
+
+ val serverHandler = acceptConnection()
+ serverHandler.expectMsgType[HttpRequest]
+ serverHandler.reply(HttpResponse(status = TemporaryRedirect, headers = Location("/go-here") :: Nil))
+
+ val serverHandler2 = acceptConnection()
+ val req = serverHandler2.expectMsgType[HttpRequest]
+ req.method === HttpMethods.GET
+ req.uri.toString === s"http://$hostname:$port/go-here"
+
+ serverHandler2.reply(HttpResponse(entity = "ok"))
+ val r = probe.expectMsgType[HttpResponse]
+ r.entity === HttpEntity("ok")
+
+ closeHostConnector(hostConnector)
+ }
+
+ "only follow one redirect when max-redirects is set to 1" in new TestSetup {
+ val redirectConf = system.settings.config withValue
+ ("spray.can.host-connector.max-redirects", ConfigValueFactory.fromAnyRef(1))
+
+ val request = Get("/def") ~> Host(hostname, port)
+ val probe = TestProbe()
+ probe.send(IO(Http), Http.HostConnectorSetup(hostname, port, settings = Some(HostConnectorSettings(redirectConf))))
+ val Http.HostConnectorInfo(hostConnector, _) = probe.expectMsgType[Http.HostConnectorInfo]
+ probe.reply(request)
+
+ val serverHandler = acceptConnection()
+ serverHandler.expectMsgType[HttpRequest]
+ serverHandler.reply(HttpResponse(status = TemporaryRedirect, headers = Location("/go-here") :: Nil))
+
+ val serverHandler2 = acceptConnection()
+ val req = serverHandler2.expectMsgType[HttpRequest]
+ req.method === HttpMethods.GET
+ req.uri.toString === s"http://$hostname:$port/go-here"
+ serverHandler2.reply(HttpResponse(status = TemporaryRedirect, headers = Location("/now-go-here") :: Nil))
+
+ val r = probe.expectMsgType[HttpResponse]
+ r.status === TemporaryRedirect
+ r.header[Location] === Some(Location("/now-go-here"))
+
+ closeHostConnector(hostConnector)
+ }
+
+ "follow 302 redirect for a POST request with a GET request" in new TestSetup {
+ val redirectConf = system.settings.config withValue
+ ("spray.can.host-connector.max-redirects", ConfigValueFactory.fromAnyRef(5))
+
+ val request = Post("/def") ~> Host(hostname, port)
+ val probe = TestProbe()
+ probe.send(IO(Http), Http.HostConnectorSetup(hostname, port, settings = Some(HostConnectorSettings(redirectConf))))
+ val Http.HostConnectorInfo(hostConnector, _) = probe.expectMsgType[Http.HostConnectorInfo]
+ probe.reply(request)
+
+ val serverHandler = acceptConnection()
+ serverHandler.expectMsgType[HttpRequest]
+ serverHandler.reply(HttpResponse(status = Found, headers = Location("/go-here") :: Nil))
+
+ val serverHandler2 = acceptConnection()
+ val req = serverHandler2.expectMsgType[HttpRequest]
+ req.method === HttpMethods.GET
+ req.uri.toString === s"http://$hostname:$port/go-here"
+
+ serverHandler2.reply(HttpResponse(entity = "ok"))
+ val r = probe.expectMsgType[HttpResponse]
+ r.entity === HttpEntity("ok")
+
+ closeHostConnector(hostConnector)
+ }
+
+ "follow 302 redirect for a HEAD request with a HEAD request" in new TestSetup {
+ val redirectConf = system.settings.config withValue
+ ("spray.can.host-connector.max-redirects", ConfigValueFactory.fromAnyRef(5))
+
+ val request = Head("/head") ~> Host(hostname, port)
+ val probe = TestProbe()
+ probe.send(IO(Http), Http.HostConnectorSetup(hostname, port, settings = Some(HostConnectorSettings(redirectConf))))
+ val Http.HostConnectorInfo(hostConnector, _) = probe.expectMsgType[Http.HostConnectorInfo]
+ probe.reply(request)
+
+ val serverHandler = acceptConnection()
+ val firstReq = serverHandler.expectMsgType[HttpRequest]
+ firstReq.method === HttpMethods.HEAD
+ serverHandler.reply(HttpResponse(status = Found, headers = Location("/go-here") :: Nil))
+
+ val serverHandler2 = acceptConnection()
+ val req = serverHandler2.expectMsgType[HttpRequest]
+ req.method === HttpMethods.HEAD
+ req.uri.toString === s"http://$hostname:$port/go-here"
+
+ serverHandler2.reply(HttpResponse(status = OK))
+ val r = probe.expectMsgType[HttpResponse]
+ r.status === OK
+
+ closeHostConnector(hostConnector)
+ }
+
+ "not follow 301 redirect for a POST request as this requires user confirmation" in new TestSetup {
+ val redirectConf = system.settings.config withValue
+ ("spray.can.host-connector.max-redirects", ConfigValueFactory.fromAnyRef(5))
+
+ val request = Post("/def") ~> Host(hostname, port)
+ val probe = TestProbe()
+ probe.send(IO(Http), Http.HostConnectorSetup(hostname, port, settings = Some(HostConnectorSettings(redirectConf))))
+ val Http.HostConnectorInfo(hostConnector, _) = probe.expectMsgType[Http.HostConnectorInfo]
+ probe.reply(request)
+
+ val serverHandler = acceptConnection()
+ serverHandler.expectMsgType[HttpRequest]
+ serverHandler.reply(HttpResponse(status = MovedPermanently, headers = Location("/go-here") :: Nil))
+
+ val r = probe.expectMsgType[HttpResponse]
+ r.status === MovedPermanently
+ r.header[Location] === Some(Location("/go-here"))
+
+ closeHostConnector(hostConnector)
+ }
}
"The request-level client infrastructure" should {
@@ -240,6 +240,13 @@ spray.can {
# giving up and returning an error.
max-retries = 5
+ # Configures redirection following.
+ # If set to zero redirection responses will not be followed, i.e. they'll be returned to the user as is.
+ # If set to a value > zero redirection responses will be followed up to the given number of times.
+ # If the redirection chain is longer than the configured value the first redirection response that is
+ # is not followed anymore is returned to the user as is.
+ max-redirects = 0
+
# If this setting is enabled, the `HttpHostConnector` pipelines requests
# across connections, otherwise only one single request can be "open"
# on a particular HTTP connection.
@@ -24,6 +24,7 @@ import spray.can.client._
import spray.can.server.HttpListener
import spray.http._
import Http.{ ClientConnectionType, HostConnectorSetup }
+import HttpHostConnector.RequestContext
private[can] class HttpManager(httpSettings: HttpExt#Settings) extends Actor with ActorLogging {
import HttpManager._
@@ -42,19 +43,22 @@ private[can] class HttpManager(httpSettings: HttpExt#Settings) extends Actor wit
case request: HttpRequest
try {
val req = request.withEffectiveUri(securedConnection = false)
- val host = req.uri.authority.host
- val connector = connectorFor(HostConnectorSetup(host.toString, req.uri.effectivePort, sslEncryption = req.uri.scheme == "https"))
+ val connector = connectorForUri(req.uri)
// never render absolute URIs here and we also drop any potentially existing fragment
- val relativeUri = Uri(
- path = if (req.uri.path.isEmpty) Uri.Path./ else req.uri.path,
- query = req.uri.query)
- connector.forward(req.copy(uri = relativeUri))
+ connector.forward(req.copy(uri = req.uri.toRelative.withoutFragment))
} catch {
case NonFatal(e)
log.error("Illegal request: {}", e.getMessage)
sender ! Status.Failure(e)
}
+ // 3xx Redirect
+ case ctx @ RequestContext(req, _, _, commander)
+ val connector = connectorForUri(req.uri)
+ // never render absolute URIs here and we also drop any potentially existing fragment
+ val newReq = req.copy(uri = req.uri.toRelative.withoutFragment)
+ connector.tell(ctx.copy(request = newReq), commander)
+
case (request: HttpRequest, setup: HostConnectorSetup)
connectorFor(setup).forward(request)
@@ -75,10 +79,10 @@ private[can] class HttpManager(httpSettings: HttpExt#Settings) extends Actor wit
case cmd: Http.CloseAll shutdownSettingsGroups(cmd, Set(sender))
}
-
- def newHttpListener(commander: ActorRef, bind: Http.Bind, httpSettings: HttpExt#Settings) =
+
+ def newHttpListener(commander: ActorRef, bind: Http.Bind, httpSettings: HttpExt#Settings) =
new HttpListener(commander, bind, httpSettings)
-
+
def withTerminationManagement(behavior: Receive): Receive = ({
case ev @ Terminated(child)
if (listeners contains child)
@@ -159,6 +163,11 @@ private[can] class HttpManager(httpSettings: HttpExt#Settings) extends Actor wit
case Terminated(child) if running contains child self.tell(Http.Unbound, child)
}
+ def connectorForUri(uri: Uri) = {
+ val host = uri.authority.host
+ connectorFor(HostConnectorSetup(host.toString, uri.effectivePort, sslEncryption = uri.scheme == "https"))
+ }
+
def connectorFor(setup: HostConnectorSetup) = {
val normalizedSetup = resolveAutoProxied(setup)
import ClientConnectionType._
@@ -205,7 +214,7 @@ private[can] class HttpManager(httpSettings: HttpExt#Settings) extends Actor wit
}
settingsGroups.getOrElse(settings, createAndRegisterSettingsGroup)
}
- def newHttpClientSettingsGroup(settings: ClientConnectionSettings, httpSettings: HttpExt#Settings) =
+ def newHttpClientSettingsGroup(settings: ClientConnectionSettings, httpSettings: HttpExt#Settings) =
new HttpClientSettingsGroup(settings, httpSettings)
}
@@ -23,19 +23,22 @@ import spray.util._
case class HostConnectorSettings(
maxConnections: Int,
maxRetries: Int,
+ maxRedirects: Int,
pipelining: Boolean,
idleTimeout: Duration,
connectionSettings: ClientConnectionSettings) {
require(maxConnections > 0, "max-connections must be > 0")
require(maxRetries >= 0, "max-retries must be >= 0")
+ require(maxRedirects >= 0, "max-redirects must be >= 0")
requirePositive(idleTimeout)
}
object HostConnectorSettings extends SettingsCompanion[HostConnectorSettings]("spray.can") {
def fromSubConfig(c: Config) = apply(
c getInt "host-connector.max-connections",
c getInt "host-connector.max-retries",
+ c getInt "host-connector.max-redirects",
c getBoolean "host-connector.pipelining",
c getDuration "host-connector.idle-timeout",
ClientConnectionSettings fromSubConfig c.getConfig("client"))
Oops, something went wrong.

0 comments on commit 98365ff

Please sign in to comment.