Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Header callback implementation for Request_Client - completes #4353 #229

Merged
merged 7 commits into from

4 participants

@acoulton
Collaborator

@kiall, I think I've implemented the remaining work on http://dev.kohanaframework.org/issues/4353 - see the update there for the details of what I've done.

The code is unit tested and passes phpcs but there may be remaining formatting issues around the nested arrays particularly. It's a bit complex in places, but I think this is a slightly complex requirement to implement.

Comments very welcome!

acoulton added some commits
@acoulton acoulton Resend request body when following redirects other than GET [refs #4353] 98451eb
@acoulton acoulton Refactor Kohana_Request_ClientTest for more powerful mocking [refs #4…
…353]

Implement a dummy controller and route to allow test cases to execute internal
requests and specify controller response through the URL. Provides more
flexibility than mocking the Request/Request_Client and allows for nested
requests.
90bc6d6
@acoulton acoulton Request_Client triggers callbacks on presence of specified headers [r…
…efs #4353]

Request_Client checks the server response and triggers user-definable callbacks
when specified headers are present. The existing logic for following redirects
is implemented as a callback - other uses would include for eg dealing with
a WWW-Authenticate header to refresh an OAuth token.

Callbacks can return a request (which will be executed with the same callbacks
and client parameters as the original request) or a response.
a8a1505
@acoulton acoulton Request callbacks are protected from too much recursion [refs #4353]
Request_Client takes a max_callback_depth property (default 5) and tracks
how many requests have been triggered by header callbacks - if the request
has too much recursion (eg an infinite redirect) the request is aborted with
a Request_Client_Recursion_Exception to allow this case to be handled by the
application developer.
ab62bf5
@acoulton acoulton Request_Client::callback_params() carries arbitrary data for header c…
…allbacks [refs #4353]

Request_Client::callback_params() provides a simple key-value store for
parameters that should be passed on to header callbacks. Parameter values
are copied into sub-requests triggered by a callback.
4e00844
@Kohana-Builds
Collaborator

Build Scheduled

@kiall

A quick read of the code, and it looks good.

I'll have a chance to test this later this evening - cmon 3.3! ;)

@Kohana-Builds
Collaborator

Build Scheduled

@kemo
Collaborator

https://github.com/acoulton/core/blob/4e00844ab77b385a103a04121c6affb0a587333a/classes/Kohana/Request/Client.php#L100

I think this should be

if ($this->callback_depth() === $this->max_callback_depth() - 1)

I didn't really read the whole code but this caught my attention

@acoulton
Collaborator

@kemo I'd need to look again and execute the relevant test, but I think that's correct - the check is executed as part of the next request execution (eg after $this->callback_depth() has been incremented and before the next recursion actually triggers a request).

Although thinking about it, it might be safer to use >=, in case the callback_depth parameter is incremented by more than one for any reason.

@acoulton acoulton Request_Client counts callback_depth from 1 for clarity [refs #4353]
callback_depth now begins at 1 (for the original request) rather than
zero. The property value is therefore directly equivalent to the number
of requests executed. For eg, after a standard request - redirect - request
pair, callback_depth will be equal to 2. The recursion limiting test case
has been updated to independently verify that the correct number of
requests were executed before the exception was thrown.
62335a6
@acoulton
Collaborator

@kemo sorry, I hadn't spotted earlier that I was using > rather than === already. I think this is safer - if a callback either accidentally increments by more than one, or executes a couple of additional requests before returning the exception will still be thrown when control eventually returns to that line.

I have however updated the test case to specifically verify the number of requests executed before the exception (rather than just checking the exception is thrown eventually). I have also changed callback_depth to start at 1 for the original request rather than 0 which I think makes more sense (so following a traditional request/redirect pair it will be 2, etc).

@Kohana-Builds
Collaborator

Build Scheduled

@kemo
Collaborator

@acoulton looks great, keep up with good work!

@kiall

I will get to this today! I will. (Tell myself that enough times and its bound to happen, right?)

Anyway - I'm pretty sure I'm happy with it, and will merge later this afternoon unless I uncover something unexpected when fully testing it out..

@kiall kiall merged commit 46b0662 into from
@kiall

Today turned into next week, but still. Done!

Looks good @acoulton - Merged, Thanks!

@acoulton
Collaborator

Great! Thanks @kiall - roll on 3.3! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Mar 4, 2012
  1. @acoulton
  2. @acoulton

    Refactor Kohana_Request_ClientTest for more powerful mocking [refs #4…

    acoulton authored
    …353]
    
    Implement a dummy controller and route to allow test cases to execute internal
    requests and specify controller response through the URL. Provides more
    flexibility than mocking the Request/Request_Client and allows for nested
    requests.
Commits on Mar 5, 2012
  1. @acoulton

    Request_Client triggers callbacks on presence of specified headers [r…

    acoulton authored
    …efs #4353]
    
    Request_Client checks the server response and triggers user-definable callbacks
    when specified headers are present. The existing logic for following redirects
    is implemented as a callback - other uses would include for eg dealing with
    a WWW-Authenticate header to refresh an OAuth token.
    
    Callbacks can return a request (which will be executed with the same callbacks
    and client parameters as the original request) or a response.
  2. @acoulton

    Request callbacks are protected from too much recursion [refs #4353]

    acoulton authored
    Request_Client takes a max_callback_depth property (default 5) and tracks
    how many requests have been triggered by header callbacks - if the request
    has too much recursion (eg an infinite redirect) the request is aborted with
    a Request_Client_Recursion_Exception to allow this case to be handled by the
    application developer.
  3. @acoulton

    Request_Client::callback_params() carries arbitrary data for header c…

    acoulton authored
    …allbacks [refs #4353]
    
    Request_Client::callback_params() provides a simple key-value store for
    parameters that should be passed on to header callbacks. Parameter values
    are copied into sub-requests triggered by a callback.
  4. @acoulton
Commits on Mar 6, 2012
  1. @acoulton

    Request_Client counts callback_depth from 1 for clarity [refs #4353]

    acoulton authored
    callback_depth now begins at 1 (for the original request) rather than
    zero. The property value is therefore directly equivalent to the number
    of requests executed. For eg, after a standard request - redirect - request
    pair, callback_depth will be equal to 2. The recursion limiting test case
    has been updated to independently verify that the correct number of
    requests were executed before the exception was thrown.
This page is out of date. Refresh to see the latest.
View
295 classes/Kohana/Request/Client.php
@@ -1,6 +1,6 @@
-<?php defined('SYSPATH') or die('No direct script access.');
+<?php defined('SYSPATH') OR die('No direct script access.');
/**
- * Request Client. Processes a [Request] and handles [HTTP_Caching] if
+ * Request Client. Processes a [Request] and handles [HTTP_Caching] if
* available. Will usually return a [Response] object as a result of the
* request unless an unexpected error occurs.
*
@@ -34,6 +34,28 @@
protected $_strict_redirect = TRUE;
/**
+ * @var array Callbacks to use when response contains given headers
+ */
+ protected $_header_callbacks = array(
+ 'Location' => 'Request_Client::on_header_location'
+ );
+
+ /**
+ * @var int Maximum number of requests that header callbacks can trigger before the request is aborted
+ */
+ protected $_max_callback_depth = 5;
+
+ /**
+ * @var int Tracks the callback depth of the currently executing request
+ */
+ protected $_callback_depth = 1;
+
+ /**
+ * @var array Arbitrary parameters that are shared with header callbacks through their Request_Client object
+ */
+ protected $_callback_params = array();
+
+ /**
* Creates a new `Request_Client` object,
* allows for dependency injection.
*
@@ -74,70 +96,58 @@ public function __construct(array $params = array())
*/
public function execute(Request $request)
{
- $response = Response::factory();
+ // Prevent too much recursion of header callback requests
+ if ($this->callback_depth() > $this->max_callback_depth())
+ throw new Request_Client_Recursion_Exception(
+ "Could not execute request to :uri - too many recursions after :depth requests",
+ array(
+ ':uri' => $request->uri(),
+ ':depth' => $this->callback_depth() - 1,
+ ));
+
+ // Execute the request
+ $orig_response = $response = Response::factory();
if (($cache = $this->cache()) instanceof HTTP_Cache)
return $cache->execute($this, $request, $response);
$response = $this->execute_request($request, $response);
- // Do we need to follow a Location header ?
- if ($this->follow() AND in_array($response->status(), array(201, 301, 302, 303, 307))
- AND $response->headers('Location'))
+ // Execute response callbacks
+ foreach ($this->header_callbacks() as $header => $callback)
{
- // Figure out which method to use for the follow request
- switch ($response->status())
+ if ($response->headers($header))
{
- default:
- case 301:
- case 307:
- $follow_method = $request->method();
- break;
- case 201:
- case 303:
- $follow_method = Request::GET;
- break;
- case 302:
- // Cater for sites with broken HTTP redirect implementations
- if ($this->strict_redirect())
- {
- $follow_method = $request->method();
- }
- else
- {
- $follow_method = Request::GET;
- }
- break;
- }
+ $cb_result = call_user_func($callback, $request, $response, $this);
- // Prepare the additional request
- $follow_request = $this->_create_request($response->headers('Location'))
- ->method($follow_method)
- ->headers(Arr::extract($request->headers(), $this->follow_headers()));
+ if ($cb_result instanceof Request)
+ {
+ // If the callback returns a request, automatically assign client params
+ $this->assign_client_properties($cb_result->client());
+ $cb_result->client()->callback_depth($this->callback_depth() + 1);
+
+ // Execute the request
+ $response = $cb_result->execute();
+ }
+ elseif ($cb_result instanceof Response)
+ {
+ // Assign the returned response
+ $response = $cb_result;
+ }
- // Execute the additional request
- $response = $follow_request->execute();
+ // If the callback has created a new response, do not process any further
+ if ($response !== $orig_response)
+ break;
+ }
}
return $response;
}
/**
- * Creates a new request object to follow a redirect (separated to allow
- * mock injection in tests).
- *
- * @param string $url The URL to pass to Request::factory
- * @return Request
- */
- protected function _create_request($url)
- {
- return Request::factory($url);
- }
-
- /**
* Processes the request passed to it and returns the response from
* the URI resource identified.
- *
+ *
* This method must be implemented by all clients.
*
* @param Request $request request to execute by client
@@ -222,4 +232,193 @@ public function strict_redirect($strict_redirect = NULL)
return $this;
}
+
+ /**
+ * Getter and setter for the header callbacks array.
+ *
+ * Accepts an array with HTTP response headers as keys and a PHP callback
+ * function as values. These callbacks will be triggered if a response contains
+ * the given header and can either issue a subsequent request or manipulate
+ * the response as required.
+ *
+ * By default, the [Request_Client::on_header_location] callback is assigned
+ * to the Location header to support automatic redirect following.
+ *
+ * $client->header_callbacks(array(
+ * 'Location' => 'Request_Client::on_header_location',
+ * 'WWW-Authenticate' => function($request, $response, $client) {return $new_response;},
+ * );
+ *
+ * @param array $header_callbacks Array of callbacks to trigger on presence of given headers
+ * @return Request_Client
+ */
+ public function header_callbacks($header_callbacks = NULL)
+ {
+ if ($header_callbacks === NULL)
+ return $this->_header_callbacks;
+
+ $this->_header_callbacks = $header_callbacks;
+
+ return $this;
+ }
+
+ /**
+ * Getter and setter for the maximum callback depth property.
+ *
+ * This protects the main execution from recursive callback execution (eg
+ * following infinite redirects, conflicts between callbacks causing loops
+ * etc). Requests will only be allowed to nest to the level set by this
+ * param before execution is aborted with a Request_Client_Recursion_Exception.
+ *
+ * @param int $depth Maximum number of callback requests to execute before aborting
+ * @return Request_Client|int
+ */
+ public function max_callback_depth($depth = NULL)
+ {
+ if ($depth === NULL)
+ return $this->_max_callback_depth;
+
+ $this->_max_callback_depth = $depth;
+
+ return $this;
+ }
+
+ /**
+ * Getter/Setter for the callback depth property, which is used to track
+ * how many recursions have been executed within the current request execution.
+ *
+ * @param int $depth Current recursion depth
+ * @return Request_Client|int
+ */
+ public function callback_depth($depth = NULL)
+ {
+ if ($depth === NULL)
+ return $this->_callback_depth;
+
+ $this->_callback_depth = $depth;
+
+ return $this;
+ }
+
+ /**
+ * Getter/Setter for the callback_params array, which allows additional
+ * application-specific parameters to be shared with callbacks.
+ *
+ * As with other Kohana setter/getters, usage is:
+ *
+ * // Set full array
+ * $client->callback_params(array('foo'=>'bar'));
+ *
+ * // Set single key
+ * $client->callback_params('foo','bar');
+ *
+ * // Get full array
+ * $params = $client->callback_params();
+ *
+ * // Get single key
+ * $foo = $client->callback_params('foo');
+ *
+ * @param string|array $param
+ * @param mixed $value
+ * @return Request_Client|mixed
+ */
+ public function callback_params($param = NULL, $value = NULL)
+ {
+ // Getter for full array
+ if ($param === NULL)
+ return $this->_callback_params;
+
+ // Setter for full array
+ if (is_array($param))
+ {
+ $this->_callback_params = $param;
+ return $this;
+ }
+ // Getter for single value
+ elseif ($value === NULL)
+ {
+ return Arr::get($this->_callback_params, $param);
+ }
+ // Setter for single value
+ else
+ {
+ $this->_callback_params[$param] = $value;
+ return $this;
+ }
+
+ }
+
+ /**
+ * Assigns the properties of the current Request_Client to another
+ * Request_Client instance - used when setting up a subsequent request.
+ *
+ * @param Request_Client $client
+ */
+ public function assign_client_properties(Request_Client $client)
+ {
+ $client->cache($this->cache());
+ $client->follow($this->follow());
+ $client->follow_headers($this->follow_headers());
+ $client->header_callbacks($this->header_callbacks());
+ $client->max_callback_depth($this->max_callback_depth());
+ $client->callback_params($this->callback_params());
+ }
+
+ /**
+ * The default handler for following redirects, triggered by the presence of
+ * a Location header in the response.
+ *
+ * The client's follow property must be set TRUE and the HTTP response status
+ * one of 201, 301, 302, 303 or 307 for the redirect to be followed.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param Request_Client $client
+ */
+ public static function on_header_location(Request $request, Response $response, Request_Client $client)
+ {
+ // Do we need to follow a Location header ?
+ if ($client->follow() AND in_array($response->status(), array(201, 301, 302, 303, 307)))
+ {
+ // Figure out which method to use for the follow request
+ switch ($response->status())
+ {
+ default:
+ case 301:
+ case 307:
+ $follow_method = $request->method();
+ break;
+ case 201:
+ case 303:
+ $follow_method = Request::GET;
+ break;
+ case 302:
+ // Cater for sites with broken HTTP redirect implementations
+ if ($client->strict_redirect())
+ {
+ $follow_method = $request->method();
+ }
+ else
+ {
+ $follow_method = Request::GET;
+ }
+ break;
+ }
+
+ // Prepare the additional request
+ $follow_request = Request::factory($response->headers('Location'))
+ ->method($follow_method)
+ ->headers(Arr::extract($request->headers(), $client->follow_headers()));
+
+ if ($follow_method !== Request::GET)
+ {
+ $follow_request->body($request->body());
+ }
+
+ return $follow_request;
+ }
+
+ return NULL;
+ }
+
}
View
10 classes/Kohana/Request/Client/Recursion/Exception.php
@@ -0,0 +1,10 @@
+<?php
+defined('SYSPATH') OR die('No direct script access.');
+/**
+ * @package Kohana
+ * @category Exceptions
+ * @author Kohana Team
+ * @copyright (c) 2009-2012 Kohana Team
+ * @license http://kohanaframework.org/license
+ */
+class Kohana_Request_Client_Recursion_Exception extends Kohana_Exception {}
View
10 classes/Request/Client/Recursion/Exception.php
@@ -0,0 +1,10 @@
+<?php
+defined('SYSPATH') OR die('No direct script access.');
+/**
+ * @package Kohana
+ * @category Exceptions
+ * @author Kohana Team
+ * @copyright (c) 2009-2012 Kohana Team
+ * @license http://kohanaframework.org/license
+ */
+class Request_Client_Recursion_Exception extends Kohana_Request_Client_Recursion_Exception {}
View
72 guide/kohana/requests.md
@@ -56,12 +56,79 @@ To execute a request, use the `execute()` method on it. This will give you a [re
$request = Request::factory('welcome');
$response = $request->execute();
-### Following redirects
-You can optionally instruct the request client to automatically follow redirects (specified with a Location header and a status code in 201, 301, 302, 303, 307). This behaviour is disabled by default, but can be enabled by passing a set of options to the Request's constructor:
+### Header callbacks
+The request client supports header callbacks - an array of callbacks that will be triggered when a specified header is included in the response from a server. Header callbacks provide a powerful way to deal with scenarios including authentication, rate limiting, redirects and other application-specific use cases:
+
+ $request = Request::factory('http://example.com/user', array(
+ 'header_callbacks' => array(
+ 'Content-Encoding' =>
+ function (Request $request, Response $response, Request_Client $client)
+ {
+ // Uncompress the response
+ $response->body(GZIP::expand($response->body()));
+ },
+ 'X-Rate-Limited' =>
+ function (Request $request, Response $response, Request_Client $client)
+ {
+ // Log the rate limit event
+ // And perhaps set a deadlock in cache to prevent further requests
+ },
+ 'WWW-Authenticate' =>
+ function (Request $request, Response $response, Request_Client $client)
+ {
+ // Execute a request to refresh your OAuth token somehow
+ // Have the original request resent
+ return Request::factory($request->uri())
+ ->query($request->query())
+ ->headers('Authorization', 'token'.$token);
+ }));
+
+Where multiple headers are present in the response, callbacks will be executed in sequence. Callbacks can be any valid PHP callback type and have three possible return types:
+
+Type | Function
+------------------|---------
+[Request] object | If a new request is returned, the request client will automatically assign properties, callbacks etc to match the original request and then execute the request. No further callbacks will be triggered for the original request, but the new request may trigger callbacks when executed.
+[Response] object | If the callback returns a new response instance it will be returned to the application. No further callbacks will be triggered for the original request. The callback is responsible for setting any relevant callbacks and properties for the request it executes
+NULL | The callback can, if required, modify the provided Response object and return NULL. The modified response object will be passed into subsequent callbacks.
+
+#### Nested requests
+If your callback returns a new Request object, the request client will apply the same callback and property definitions to it before execution. This allows for nested requests - for example, you might need to re-authenticate before submitting a POST request and then being redirected to a new location. To avoid infinite recursion and fatal errors, the request client keeps track of the number of subrequests and will throw a [Request_Client_Recursion_Exception] if the recursion gets too deep. This behaviour is controlled by two properties: [Request_Client::callback_depth()] and [Request_Client::max_callback_depth()]. The default limit is 5 subrequests.
+
+If your callback executes a new request itself and returns the response, it is responsible for dealing with any callbacks and request nesting itself. You may find the [Request_Client::assign_client_properties()] method useful in this case.
+
+#### Callback parameters
+Arbitrary parameters can be passed to the callbacks through the [Request_Client::callback_params()] property:
+
+ $request = Request::factory('http://example.com/foo', array(
+ 'header_callbacks' => array(
+ 'X-Custom-1' =>
+ function (Request $request, Response $response, Request_Client $client)
+ {
+ // Do something that needs an external parameter
+ if ($client->callback_params('foo') == 'bar')
+ {
+ // etc
+ }
+ },
+ )
+ 'callback_params' => array(
+ 'foo' => 'bar'
+ )
+ ));
+
+ // later on
+ $request->client()->callback_params('foo',FALSE);
+
+As with nested requests, callback_params will automatically be passed to subrequests if the callback returns a new Request object. If the callback returns a Response object, it is responsible for passing on any relevant parameters.
+
+#### Following redirects
+The request client ships with a standard callback to automatically follow redirects - [Request_Client::on_header_location()]. This will recursively follow redirects that are specified with a Location header and a status code in 201, 301, 302, 303, 307. This behaviour is disabled by default, but can be enabled by passing a set of options to the Request's constructor:
$request = Request::factory('http://example.com/redirectme', array(
'follow' => TRUE));
+[!!] If you define additional header callbacks of your own, you will need to include the 'Location' callback in your callbacks array.
+
A number of options are available to control the behaviour of the [Request_Client] when following redirects.
Option |Default |Function
@@ -72,6 +139,7 @@ strict_redirect | TRUE |Whether to use the original request m
[!!] HTTP/1.1 specifies that a 302 redirect should be followed using the original request method. However, the vast majority of clients and servers get this wrong, with 302 widely used for 'POST - 302 redirect - GET' patterns. By default, Kohana's client is fully compliant with the HTTP spec. If you need to interact with non-compliant third party sites you may need to set strict_redirect FALSE to force the client to switch to GET following a 302 response.
+You can easily alter this behaviour by configuring your own 'Location' header callback.
## Request Cache Control
View
443 tests/kohana/request/ClientTest.php
@@ -17,31 +17,115 @@
class Kohana_Request_ClientTest extends Unittest_TestCase
{
protected $_inital_request;
+ protected static $_original_routes;
+ // @codingStandardsIgnoreStart - PHPUnit does not follow standards
+ /**
+ * Sets up a new route to ensure that we have a matching route for our
+ * Controller_RequestClientDummy class.
+ */
+ public static function setUpBeforeClass()
+ {
+ // @codingStandardsIgnoreEnd
+ parent::setUpBeforeClass();
+
+ // Set a new Route to the ClientTest controller as the first route
+ // This requires reflection as the API for editing defined routes is limited
+ $route_class = new ReflectionClass('Route');
+ $routes_prop = $route_class->getProperty('_routes');
+ $routes_prop->setAccessible(TRUE);
+
+ self::$_original_routes = $routes_prop->getValue('Route');
+
+ $routes = array(
+ 'ko_request_clienttest' => new Route('<controller>/<action>/<data>',array('data'=>'.+'))
+ ) + self::$_original_routes;
+
+ $routes_prop->setValue('Route',$routes);
+
+ }
+
+ // @codingStandardsIgnoreStart - PHPUnit does not follow standards
+ /**
+ * Resets the application's routes to their state prior to this test case
+ */
+ public static function tearDownAfterClass()
+ {
+ // @codingStandardsIgnoreEnd
+ // Reset routes
+ $route_class = new ReflectionClass('Route');
+ $routes_prop = $route_class->getProperty('_routes');
+ $routes_prop->setAccessible(TRUE);
+ $routes_prop->setValue('Route',self::$_original_routes);
+
+ parent::tearDownAfterClass();
+ }
+
+ // @codingStandardsIgnoreStart - PHPUnit does not follow standards
public function setUp()
{
+ // @codingStandardsIgnoreEnd
parent::setUp();
$this->_initial_request = Request::$initial;
Request::$initial = new Request('/');
}
+ // @codingStandardsIgnoreStart - PHPUnit does not follow standards
public function tearDown()
{
+ // @codingStandardsIgnoreEnd
Request::$initial = $this->_initial_request;
parent::tearDown();
}
/**
+ * Generates an internal URI to the [Controller_RequestClientDummy] shunt
+ * controller - the URI contains an encoded form of the required server
+ * response.
+ *
+ * @param string $status HTTP response code to issue
+ * @param array $headers HTTP headers to send with the response
+ * @param string $body A string to send back as response body (included in the JSON response)
+ * @return string
+ */
+ protected function _dummy_uri($status, $headers, $body)
+ {
+ $data = array(
+ 'status' => $status,
+ 'header' => $headers,
+ 'body' => $body
+ );
+ return "/requestclientdummy/fake".'/'.urlencode(http_build_query($data));
+ }
+
+ /**
+ * Shortcut method to generate a simple redirect URI - the first request will
+ * receive a redirect with the given HTTP status code and the second will
+ * receive a 200 response. The 'body' data value in the first response will
+ * be 'not-followed' and in the second response it will be 'followed'. This
+ * allows easy assertion that a redirect has taken place.
+ *
+ * @param string $status HTTP response code to issue
+ * @return string
+ */
+ protected function _dummy_redirect_uri($status)
+ {
+ return $this->_dummy_uri($status,
+ array('Location' => $this->_dummy_uri(200, NULL, 'followed')),
+ 'not-followed');
+ }
+
+ /**
* Provider for test_follows_redirects
* @return array
*/
public function provider_follows_redirects()
{
return array(
- array(TRUE, '200', array(), FALSE),
- array(TRUE, '200', array('location' => 'http://foo.com/'), FALSE),
- array(TRUE, '302', array('location' => 'http://foo.com/'), TRUE),
- array(FALSE, '302', array('location' => 'http://foo.com/'), FALSE)
+ array(TRUE, $this->_dummy_uri(200, NULL, 'not-followed'), 'not-followed'),
+ array(TRUE, $this->_dummy_redirect_uri(200), 'not-followed'),
+ array(TRUE, $this->_dummy_redirect_uri(302), 'followed'),
+ array(FALSE, $this->_dummy_redirect_uri(302), 'not-followed'),
);
}
@@ -51,31 +135,17 @@ public function provider_follows_redirects()
* @dataProvider provider_follows_redirects
*
* @param bool $follow Option value to set
- * @param string $response_status HTTP response status to fake for initial request
- * @param array $response_headers HTTP response headers to fake for initial request
- * @param bool $expect_follow Whether to expect the client to attempt to follow redirect
+ * @param string $request_url URL to request initially (contains data to set up redirect etc)
+ * @param string $expect_body Body text expected in the eventual result
*/
- public function test_follows_redirects($follow, $response_status, $response_headers, $expect_follow)
+ public function test_follows_redirects($follow, $request_url, $expect_body)
{
- $client = new Request_Client_FollowTest_Dummy(array(
- 'follow' => $follow
- ));
-
- $client->test_response_status = $response_status;
- $client->test_response_headers = $response_headers;
-
- Request::factory('http://bar.com/')
- ->client($client)
+ $response = Request::factory($request_url,
+ array('follow' => $follow))
->execute();
- if ($expect_follow)
- {
- $this->assertInstanceOf('Request',$client->test_follow_request);
- }
- else
- {
- $this->assertNull($client->test_follow_request);
- }
+ $data = json_decode($response->body(), TRUE);
+ $this->assertEquals($expect_body, $data['body']);
}
/**
@@ -83,27 +153,26 @@ public function test_follows_redirects($follow, $response_status, $response_head
*/
public function test_follows_with_headers()
{
- $client = new Request_Client_FollowTest_Dummy(array(
- 'follow' => TRUE,
- 'follow_headers' => array('Authorization','X-Follow-With-Value')
- ));
-
- $client->test_response_status = '301';
- $client->test_response_headers = array('location' => 'http://foo.com/');
+ $response = Request::factory(
+ $this->_dummy_redirect_uri(301),
+ array(
+ 'follow' => TRUE,
+ 'follow_headers' => array('Authorization', 'X-Follow-With-Value')
+ ))
+ ->headers(array(
+ 'Authorization' => 'follow',
+ 'X-Follow-With-Value' => 'follow',
+ 'X-Not-In-Follow' => 'no-follow'
+ ))
+ ->execute();
- $response = Request::factory('http://bar.com')
- ->client($client)
- ->headers(array(
- 'Authorization' => 'follow',
- 'X-Follow-With-Value' => 'follow',
- 'X-Not-In-Follow' => 'no-follow'
- ))
- ->execute();
+ $data = json_decode($response->body(),TRUE);
+ $headers = $data['rq_headers'];
- $follow_request = $client->test_follow_request;
- $this->assertEquals($follow_request->headers('Authorization'),'follow');
- $this->assertEquals($follow_request->headers('X-Follow-With-Value'),'follow');
- $this->assertNull($follow_request->headers('X-Not-In-Follow'));
+ $this->assertEquals('followed', $data['body']);
+ $this->assertEquals('follow', $headers['authorization']);
+ $this->assertEquals('follow', $headers['x-follow-with-value']);
+ $this->assertFalse(isset($headers['x-not-in-follow']), 'X-Not-In-Follow should not be passed to next request');
}
/**
@@ -136,60 +205,276 @@ public function provider_follows_with_strict_method()
*/
public function test_follows_with_strict_method($status_code, $strict_redirect, $orig_method, $expect_method)
{
- $client = new Request_Client_FollowTest_Dummy(array(
- 'follow' => TRUE,
- 'strict_redirect' => $strict_redirect
- ));
+ $response = Request::factory($this->_dummy_redirect_uri($status_code),
+ array(
+ 'follow' => TRUE,
+ 'strict_redirect' => $strict_redirect
+ ))
+ ->method($orig_method)
+ ->execute();
- $client->test_response_status = $status_code;
- $client->test_response_headers = array('location' => 'http://foo.com/');
+ $data = json_decode($response->body(), TRUE);
- Request::factory('http://bar.com')
- ->client($client)
- ->method($orig_method)
+ $this->assertEquals('followed', $data['body']);
+ $this->assertEquals($expect_method, $data['rq_method']);
+ }
+
+ /**
+ * Provider for test_follows_with_body_if_not_get
+ *
+ * @return array
+ */
+ public function provider_follows_with_body_if_not_get()
+ {
+ return array(
+ array('GET','301',NULL),
+ array('POST','303',NULL),
+ array('POST','307','foo-bar')
+ );
+ }
+
+ /**
+ * Tests that the original request body is sent when following a redirect
+ * (unless redirect method is GET)
+ *
+ * @dataProvider provider_follows_with_body_if_not_get
+ * @depends test_follows_with_strict_method
+ * @depends test_follows_redirects
+ *
+ * @param string $original_method Request method to use for the original request
+ * @param string $status Redirect status that will be issued
+ * @param string $expect_body Expected value of body() in the second request
+ */
+ public function test_follows_with_body_if_not_get($original_method, $status, $expect_body)
+ {
+ $response = Request::factory($this->_dummy_redirect_uri($status),
+ array('follow' => TRUE))
+ ->method($original_method)
+ ->body('foo-bar')
->execute();
- $this->assertEquals($client->test_follow_request->method(), $expect_method);
+ $data = json_decode($response->body(), TRUE);
+
+ $this->assertEquals('followed', $data['body']);
+ $this->assertEquals($expect_body, $data['rq_body']);
}
-} // End Kohana_Request_ClientTest
+ /**
+ * Provider for test_triggers_header_callbacks
+ *
+ * @return array
+ */
+ public function provider_triggers_header_callbacks()
+ {
+ return array(
+ // Straightforward response manipulation
+ array(
+ array('X-test-1' =>
+ function($request, $response, $client){
+ $response->body(json_encode(array('body'=>'test1-body-changed')));
+ return $response;
+ }),
+ $this->_dummy_uri(200, array('X-test-1' => 'foo'), 'test1-body'),
+ 'test1-body-changed'
+ ),
+ // Subsequent request execution
+ array(
+ array('X-test-2' =>
+ function($request, $response, $client){
+ return Request::factory($response->headers('X-test-2'));
+ }),
+ $this->_dummy_uri(200,
+ array('X-test-2' => $this->_dummy_uri(200, NULL, 'test2-subsequent-body')),
+ 'test2-orig-body'),
+ 'test2-subsequent-body'
+ ),
+ // No callbacks triggered
+ array(
+ array('X-test-3' =>
+ function ($request, $response, $client) {
+ throw new Exception("Unexpected execution of X-test-3 callback");
+ }),
+ $this->_dummy_uri(200, array('X-test-1' => 'foo'), 'test3-body'),
+ 'test3-body'
+ ),
+ // Callbacks not triggered once a previous callback has created a new response
+ array(
+ array(
+ 'X-test-1' =>
+ function($request, $response, $client){
+ return Request::factory($response->headers('X-test-1'));
+ },
+ 'X-test-2' =>
+ function($request, $response, $client){
+ return Request::factory($response->headers('X-test-2'));
+ }
+ ),
+ $this->_dummy_uri(200,
+ array(
+ 'X-test-1' => $this->_dummy_uri(200, NULL, 'test1-subsequent-body'),
+ 'X-test-2' => $this->_dummy_uri(200, NULL, 'test2-subsequent-body')
+ ),
+ 'test2-orig-body'),
+ 'test1-subsequent-body'
+ ),
+ // Nested callbacks are supported if callback creates new request
+ array(
+ array(
+ 'X-test-1' =>
+ function($request, $response, $client){
+ return Request::factory($response->headers('X-test-1'));
+ },
+ 'X-test-2' =>
+ function($request, $response, $client){
+ return Request::factory($response->headers('X-test-2'));
+ }
+ ),
+ $this->_dummy_uri(200,
+ array(
+ 'X-test-1' => $this->_dummy_uri(
+ 200,
+ array('X-test-2' => $this->_dummy_uri(200, NULL, 'test2-subsequent-body')),
+ 'test1-subsequent-body'),
+ ),
+ 'test-orig-body'),
+ 'test2-subsequent-body'
+ ),
+ );
+ }
+ /**
+ * Tests that header callbacks are triggered in sequence when specific headers
+ * are present in the response
+ *
+ * @dataProvider provider_triggers_header_callbacks
+ *
+ * @param array $callbacks Array of header callbacks
+ * @param array $headers Headers that will be received in the response
+ * @param string $expect_body Response body content to expect
+ */
+ public function test_triggers_header_callbacks($callbacks, $uri, $expect_body)
+ {
+ $response = Request::factory($uri,
+ array('header_callbacks' => $callbacks))
+ ->execute();
-/**
- * Test harness to allow mocking and testing of redirect following behaviour
- */
-class Request_Client_FollowTest_Dummy extends Request_Client
-{
- public $test_response_status = NULL;
- public $test_response_headers = array();
- public $test_follow_request = NULL;
+ $data = json_decode($response->body(), TRUE);
+ $this->assertEquals($expect_body, $data['body']);
+ }
+
/**
- * Fakes the response status and headers
+ * Tests that the Request_Client is protected from too many recursions of
+ * requests triggered by header callbacks.
+ *
+ */
+ public function test_deep_recursive_callbacks_are_aborted()
+ {
+ $uri = $this->_dummy_uri('200', array('x-cb' => '1'), 'body');
+
+ // Temporary property to track requests
+ $this->requests_executed = 0;
+
+ try
+ {
+ $response = Request::factory(
+ $uri,
+ array(
+ 'header_callbacks' => array(
+ 'x-cb' => function ($request, $response, $client)
+ {
+ $client->callback_params('testcase')->requests_executed++;
+ // Recurse into a new request
+ return Request::factory($request->uri());
+ }),
+ 'max_callback_depth' => 2,
+ 'callback_params' => array(
+ 'testcase' => $this,
+ )
+ ))
+ ->execute();
+ }
+ catch (Request_Client_Recursion_Exception $e)
+ {
+ // Verify that two requests were executed
+ $this->assertEquals(2, $this->requests_executed);
+ return;
+ }
+
+ $this->fail('Expected Request_Client_Recursion_Exception was not thrown');
+ }
+
+ /**
+ * Header callback for testing that arbitrary callback_params are available
+ * to the callback.
*
* @param Request $request
* @param Response $response
- * @return Response
+ * @param Request_Client $client
*/
- public function execute_request(Request $request, Response $response)
+ public function callback_assert_params($request, $response, $client)
{
- $response->headers($this->test_response_headers);
- $response->status($this->test_response_status);
- return $response;
+ $this->assertEquals('foo', $client->callback_params('constructor_param'));
+ $this->assertEquals('bar', $client->callback_params('setter_param'));
+ $response->body('assertions_ran');
}
/**
- * Mocks a new Request to use for following the redirect
- * @param string $url
- * @return Request
+ * Test that arbitrary callback_params can be passed to the callback through
+ * the Request_Client and are assigned to subsequent requests
*/
- protected function _create_request($url)
+ public function test_client_can_hold_params_for_callbacks()
{
- $this->test_follow_request = PHPUnit_Framework_MockObject_Generator::getMock(
- 'Request',
- array('execute'),
- array($url));
+ // Test with param in constructor
+ $request = Request::factory(
+ $this->_dummy_uri(
+ 302,
+ array('Location' => $this->_dummy_uri('200',array('X-cb'=>'1'), 'followed')),
+ 'not-followed'),
+ array(
+ 'follow' => TRUE,
+ 'header_callbacks' => array(
+ 'x-cb' => array($this, 'callback_assert_params'),
+ 'location' => 'Request_Client::on_header_location',
+ ),
+ 'callback_params' => array(
+ 'constructor_param' => 'foo'
+ )
+ ));
- return $this->test_follow_request;
+ // Test passing param to setter
+ $request->client()->callback_params('setter_param', 'bar');
+
+ // Callback will throw assertion exceptions when executed
+ $response = $request->execute();
+ $this->assertEquals('assertions_ran', $response->body());
}
-} // End Request_Client_FollowTest_Dummy
+
+} // End Kohana_Request_ClientTest
+
+
+/**
+ * Dummy controller class that acts as a shunt - passing back request information
+ * in the response to allow inspection.
+ */
+class Controller_RequestClientDummy extends Controller {
+
+ /**
+ * Takes a urlencoded 'data' parameter from the route and uses it to craft a
+ * response. Redirect chains can be tested by passing another encoded uri
+ * as a location header with an appropriate status code.
+ */
+ public function action_fake()
+ {
+ parse_str(urldecode($this->request->param('data')), $data);
+ $this->response->status(Arr::get($data, 'status', 200));
+ $this->response->headers(Arr::get($data, 'header', array()));
+ $this->response->body(json_encode(array(
+ 'body'=> Arr::get($data,'body','ok'),
+ 'rq_headers' => $this->request->headers(),
+ 'rq_body' => $this->request->body(),
+ 'rq_method' => $this->request->method(),
+ )));
+ }
+
+} // End Controller_RequestClientDummy
Something went wrong with that request. Please try again.