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.