From 6c5a558b851cb4160df27b22b0c5d5036136ae2c Mon Sep 17 00:00:00 2001 From: Jeremy Lindblom Date: Fri, 21 Oct 2016 16:35:27 -0700 Subject: [PATCH] Added support for using custom HTTP clients in a backward-compatible way. Also added a test suite that covers the HTTPMessage and client classes. --- .editorconfig | 11 ++ .gitignore | 7 ++ composer.json | 16 ++- phpunit.xml.dist | 21 ++++ src/HTTP/Client.php | 27 +++++ src/HTTP/CurlClient.php | 69 ++++++++++++ src/HTTP/StreamClient.php | 55 ++++++++++ src/HTTPMessage.php | 225 +++++++++++++++----------------------- tests/HTTP/ClientTest.php | 108 ++++++++++++++++++ tests/HTTP/TestServer.php | 77 +++++++++++++ tests/HTTP/server.php | 18 +++ tests/HTTPMessageTest.php | 83 ++++++++++++++ 12 files changed, 579 insertions(+), 138 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 phpunit.xml.dist create mode 100644 src/HTTP/Client.php create mode 100644 src/HTTP/CurlClient.php create mode 100644 src/HTTP/StreamClient.php create mode 100644 tests/HTTP/ClientTest.php create mode 100644 tests/HTTP/TestServer.php create mode 100644 tests/HTTP/server.php create mode 100644 tests/HTTPMessageTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..eab48cc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending for every file +# Indent with 4 spaces +[php] +end_of_line = lf +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e35df3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +phpunit.xml +composer.phar +composer.lock +vendor/ +build/ +.idea +.DS_STORE diff --git a/composer.json b/composer.json index 924ee5c..286d286 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "imsglobal/lti", "version" : "3.0.2", "description": "LTI Tool Provider Library", - "keywords": ["lti"], + "keywords": ["lti", "ims", "content-item", "edtech", "education", "lms"], "homepage": "https://www.imsglobal.org/lti", "type": "library", "license": "Apache-2.0", @@ -19,5 +19,19 @@ "psr-4": { "IMSGlobal\\LTI\\": "src/" } + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^5.6" + }, + "autoload-dev":{ + "psr-4": { + "IMSGlobal\\LTI\\Test\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "coverage": "phpunit --coverage-html=build/coverage", + "coverage-text": "phpunit --coverage-text" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..0015ede --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + + tests/ + + + + + + src + + + + + + + + diff --git a/src/HTTP/Client.php b/src/HTTP/Client.php new file mode 100644 index 0000000..b8af6d9 --- /dev/null +++ b/src/HTTP/Client.php @@ -0,0 +1,27 @@ + + * @copyright IMS Global Learning Consortium Inc + * @date 2016 + * @version 3.0.0 + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 + */ +interface Client +{ + + /** + * Send the provided HTTPMessage and then updates it with the response data. + * + * @param HTTPMessage $message The HTTP message to send + * @return bool If successful, returns true + */ + public function send(HTTPMessage $message); + +} diff --git a/src/HTTP/CurlClient.php b/src/HTTP/CurlClient.php new file mode 100644 index 0000000..08d1dc2 --- /dev/null +++ b/src/HTTP/CurlClient.php @@ -0,0 +1,69 @@ + + * @copyright IMS Global Learning Consortium Inc + * @date 2016 + * @version 3.0.0 + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 + */ +class CurlClient implements Client +{ + + /** + * @inheritdoc + */ + public function send(HTTPMessage $message) + { + $message->ok = false; + + $resp = ''; + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $message->url); + if (!empty($message->requestHeaders)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $message->requestHeaders); + } else { + curl_setopt($ch, CURLOPT_HEADER, 0); + } + if ($message->method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $message->request); + } else if ($message->method !== 'GET') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $message->method); + if (!is_null($message->request)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $message->request); + } + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLINFO_HEADER_OUT, true); + curl_setopt($ch, CURLOPT_HEADER, true); + $chResp = curl_exec($ch); + $message->ok = $chResp !== false; + if ($message->ok) { + $chResp = str_replace("\r\n", "\n", $chResp); + $chRespSplit = explode("\n\n", $chResp, 2); + if ((count($chRespSplit) > 1) && (substr($chRespSplit[1], 0, 5) === 'HTTP/')) { + $chRespSplit = explode("\n\n", $chRespSplit[1], 2); + } + $message->responseHeaders = $chRespSplit[0]; + $resp = $chRespSplit[1]; + $message->status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $message->ok = $message->status < 400; + if (!$message->ok) { + $message->error = curl_error($ch); + } + } + $message->requestHeaders = str_replace("\r\n", "\n", curl_getinfo($ch, CURLINFO_HEADER_OUT)); + curl_close($ch); + $message->response = $resp; + + return $message->ok; + } + +} diff --git a/src/HTTP/StreamClient.php b/src/HTTP/StreamClient.php new file mode 100644 index 0000000..7bd6f8e --- /dev/null +++ b/src/HTTP/StreamClient.php @@ -0,0 +1,55 @@ + + * @copyright IMS Global Learning Consortium Inc + * @date 2016 + * @version 3.0.0 + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0 + */ +class StreamClient implements Client +{ + + /** + * @inheritdoc + */ + public function send(HTTPMessage $message) + { + $message->ok = false; + + // Prepare options for the HTTP context. + $opts = array( + 'method' => $message->method, + 'content' => $message->request + ); + if (!empty($message->requestHeaders)) { + $opts['header'] = $message->requestHeaders; + } + + // Send the request. + $http_response_header = null; + $context = stream_context_create(['http' => $opts]); + $stream = @fopen($message->url, 'rb', false, $context); + if ($stream) { + $message->response = @stream_get_contents($stream); + fclose($stream); + } + + // Read the headers to get the status. + if ($http_response_header) { + $message->responseHeaders = implode("\n", $http_response_header); + $parts = explode(' ', $message->responseHeaders, 3); + $message->status = $parts[1]; + $message->ok = $message->status < 400; + } + + return $message->ok; + } + +} diff --git a/src/HTTPMessage.php b/src/HTTPMessage.php index 1fa2a9a..a2ae09b 100644 --- a/src/HTTPMessage.php +++ b/src/HTTPMessage.php @@ -2,8 +2,12 @@ namespace IMSGlobal\LTI; +use IMSGlobal\LTI\HTTP\Client as HTTPClient; +use IMSGlobal\LTI\HTTP\CurlClient; +use IMSGlobal\LTI\HTTP\StreamClient; + /** - * Class to represent an HTTP message + * Class to represent an HTTP message. * * @author Stephen P Vickers * @copyright IMS Global Learning Consortium Inc @@ -13,167 +17,114 @@ */ class HTTPMessage { - -/** - * True if message was sent successfully. - * - * @var boolean $ok - */ + /** + * @var HTTPClient The client used to send the request. + */ + private static $httpClient; + + /** + * @var bool True if message was sent successfully. + */ public $ok = false; -/** - * Request body. - * - * @var request $request - */ + /** + * @var string|null Request body. + */ public $request = null; -/** - * Request headers. - * - * @var request_headers $requestHeaders - */ - public $requestHeaders = ''; + /** + * @var array Request headers. + */ + public $requestHeaders = []; -/** - * Response body. - * - * @var response $response - */ + /** + * @var string|null Response body. + */ public $response = null; -/** - * Response headers. - * - * @var response_headers $responseHeaders - */ + /** + * @var string Response headers. + */ public $responseHeaders = ''; -/** - * Status of response (0 if undetermined). - * - * @var status $status - */ + /** + * @var int Status of response (0 if undetermined). + */ public $status = 0; -/** - * Error message - * - * @var error $error - */ + /** + * @var string Error message + */ public $error = ''; -/** - * Request URL. - * - * @var url $url - */ - private $url = null; + /** + * @var string Request URL. + */ + public $url = null; + + /** + * @var string Request method. + */ + public $method = null; + + /** + * Allows you to set a custom HTTP client. + * + * @param HTTPClient|null $httpClient The HTTP client to use for sending message. + */ + public static function setHttpClient(HTTPClient $httpClient = null) + { + self::$httpClient = $httpClient; + } -/** - * Request method. - * - * @var method $method - */ - private $method = null; + /** + * Retrieves the HTTP client used for sending the message. Creates a default client if one is not set. + * + * @return HTTPClient + */ + public static function getHttpClient() + { + if (!self::$httpClient) { + // @codeCoverageIgnoreStart + if (function_exists('curl_init')) { + self::$httpClient = new CurlClient(); + } elseif (ini_get('allow_url_fopen')) { + self::$httpClient = new StreamClient(); + } else { + throw new \RuntimeException('Cannot create an HTTP client, because neither cURL or allow_url_fopen are enabled.'); + } + // @codeCoverageIgnoreEnd + } + + return self::$httpClient; + } -/** - * Class constructor. - * - * @param string $url URL to send request to - * @param string $method Request method to use (optional, default is GET) - * @param mixed $params Associative array of parameter values to be passed or message body (optional, default is none) - * @param string $header Values to include in the request header (optional, default is none) - */ + /** + * Class constructor. + * + * @param string $url URL to send request to + * @param string $method Request method to use (optional, default is GET) + * @param array|string $params Associative array of parameter values to be passed or message body (optional, default is none) + * @param array|string $header Values to include in the request header (optional, default is none) + */ function __construct($url, $method = 'GET', $params = null, $header = null) { - $this->url = $url; $this->method = strtoupper($method); - if (is_array($params)) { - $this->request = http_build_query($params); - } else { - $this->request = $params; - } - if (!empty($header)) { + $this->request = is_array($params) ? http_build_query($params) : $params; + if ($header && !is_array($header)) { $this->requestHeaders = explode("\n", $header); } - } -/** - * Send the request to the target URL. - * - * @return boolean True if the request was successful - */ + /** + * Send the request to the target URL. + * + * @return boolean True if the request was successful + */ public function send() { - - $this->ok = false; -// Try using curl if available - if (function_exists('curl_init')) { - $resp = ''; - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $this->url); - if (!empty($this->requestHeaders)) { - curl_setopt($ch, CURLOPT_HTTPHEADER, $this->requestHeaders); - } else { - curl_setopt($ch, CURLOPT_HEADER, 0); - } - if ($this->method === 'POST') { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $this->request); - } else if ($this->method !== 'GET') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method); - if (!is_null($this->request)) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $this->request); - } - } - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLINFO_HEADER_OUT, true); - curl_setopt($ch, CURLOPT_HEADER, true); - //curl_setopt($ch, CURLOPT_SSLVERSION,3); - $chResp = curl_exec($ch); - $this->ok = $chResp !== false; - if ($this->ok) { - $chResp = str_replace("\r\n", "\n", $chResp); - $chRespSplit = explode("\n\n", $chResp, 2); - if ((count($chRespSplit) > 1) && (substr($chRespSplit[1], 0, 5) === 'HTTP/')) { - $chRespSplit = explode("\n\n", $chRespSplit[1], 2); - } - $this->responseHeaders = $chRespSplit[0]; - $resp = $chRespSplit[1]; - $this->status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $this->ok = $this->status < 400; - if (!$this->ok) { - $this->error = curl_error($ch); - } - } - $this->requestHeaders = str_replace("\r\n", "\n", curl_getinfo($ch, CURLINFO_HEADER_OUT)); - curl_close($ch); - $this->response = $resp; - } else { -// Try using fopen if curl was not available - $opts = array('method' => $this->method, - 'content' => $this->request - ); - if (!empty($this->requestHeaders)) { - $opts['header'] = $this->requestHeaders; - } - try { - $ctx = stream_context_create(array('http' => $opts)); - $fp = @fopen($this->url, 'rb', false, $ctx); - if ($fp) { - $resp = @stream_get_contents($fp); - $this->ok = $resp !== false; - } - } catch (\Exception $e) { - $this->ok = false; - } - } - - return $this->ok; - + return self::getHttpClient()->send($this); } } diff --git a/tests/HTTP/ClientTest.php b/tests/HTTP/ClientTest.php new file mode 100644 index 0000000..d9d644b --- /dev/null +++ b/tests/HTTP/ClientTest.php @@ -0,0 +1,108 @@ +server = new TestServer(); + $this->server->start(); + if (!$this->server->isRunning()) { + $this->markTestSkipped('Test server is not running.'); + } + } + + protected function tearDown() + { + parent::tearDown(); + $this->server->stop(); + } + + public function provideClient() + { + return [[new CurlClient], [new StreamClient]]; + } + + /** + * @param HttpClient $client + * @dataProvider provideClient + */ + public function testCanSendPostMessage(HttpClient $client) + { + $url = $this->server->getUrl(200, TestServer::RETURN_INPUT); + $message = new HTTPMessage($url, 'POST', 'hello'); + $client->send($message); + + $this->assertTrue($message->ok); + $this->assertEquals('hello', $message->response); + } + + /** + * @param HttpClient $client + * @dataProvider provideClient + */ + public function testCanSendUncommonMethods(HttpClient $client) + { + $url = $this->server->getUrl(200, TestServer::RETURN_INPUT); + $message = new HTTPMessage($url, 'PATCH', 'hello'); + $client->send($message); + + $this->assertTrue($message->ok); + $this->assertEquals('hello', $message->response); + } + + /** + * @param HttpClient $client + * @dataProvider provideClient + */ + public function testCanSendMessageWithHeaders(HttpClient $client) + { + $url = $this->server->getUrl(200, TestServer::RETURN_HEADER, 'Test'); + $message = new HTTPMessage($url, 'POST', null, 'Test: foo'); + $client->send($message); + + $this->assertTrue($message->ok); + $this->assertEquals('foo', $message->response); + } + + /** + * @param HttpClient $client + * @dataProvider provideClient + */ + public function testCanHandleError(HttpClient $client) + { + $url = $this->server->getUrl(404); + $message = new HTTPMessage($url, 'GET'); + $client->send($message); + + $this->assertFalse($message->ok); + $this->assertEquals(404, $message->status); + } + + public function testCanHandleResponsesWithRepeatedHeaderBlock() + { + $url = $this->server->getUrl(200, TestServer::RETURN_VALUE, "HTTP/1.1 200 OK\n\nsuccess"); + $message = new HTTPMessage($url, 'GET'); + $client = new CurlClient(); + $client->send($message); + + $this->assertTrue($message->ok); + $this->assertEquals('success', $message->response); + } +} diff --git a/tests/HTTP/TestServer.php b/tests/HTTP/TestServer.php new file mode 100644 index 0000000..39689a6 --- /dev/null +++ b/tests/HTTP/TestServer.php @@ -0,0 +1,77 @@ +getPort(); + $this->testServerPid = @exec("php -S localhost:{$port} -t {$dir} {$dir}/server.php &> /dev/null & echo $!"); + + // Wait a little bit for the server to come online. + usleep(100000); + } + + /** + * Determines if the server is running. + * + * @return bool + */ + public function isRunning() + { + $ping = @file_get_contents($url = $this->getUrl(200, self::RETURN_VALUE, 'ping')); + return $ping === 'ping'; + } + + /** + * Gets the URL to the test server. + * + * @return string Test server's URL + */ + public function getUrl($status = 200, $type = self::RETURN_NOTHING, $value = null) + { + $port = $this->getPort(); + $query = http_build_query(compact('status', 'type', 'value')); + + return "http://localhost:{$port}/?{$query}"; + } + + /** + * Stop the test server, if it's running, and the PID is known. + */ + public function stop() + { + if (is_numeric($this->testServerPid)) { + @exec("kill {$this->testServerPid}"); + } + } + + /** + * Get the test server port, as provided in phpunit.xml(.dist). + * + * @return int + */ + private function getPort() + { + if (isset($_SERVER['TEST_SERVER_PORT']) && is_numeric($_SERVER['TEST_SERVER_PORT'])) { + return (int) $_SERVER['TEST_SERVER_PORT']; + } else { + throw new \RuntimeException('TEST_SERVER_PORT is not defined as a $_SERVER variable in your phpunit.xml'); + } + } +} diff --git a/tests/HTTP/server.php b/tests/HTTP/server.php new file mode 100644 index 0000000..a2c0313 --- /dev/null +++ b/tests/HTTP/server.php @@ -0,0 +1,18 @@ +createMock(Client::class); + HTTPMessage::setHttpClient($client); + + $this->assertInstanceOf(Client::class, HTTPMessage::getHttpClient()); + } + + public function testUsesCurlClientIfCurlIsAvailable() + { + $client = HTTPMessage::getHttpClient(); + + $this->assertInstanceOf(CurlClient::class, $client); + } + + public function testCanCreateAFormattedHttpMessage() + { + $message = new HTTPMessage( + 'http://example.com', + 'post', + ['a' => 1, 'b' => 2], + "Foo: abc\nBar: xyz" + ); + + $this->assertEquals('http://example.com', $message->url); + $this->assertEquals('POST', $message->method); + $this->assertEquals('a=1&b=2', $message->request); + $this->assertInternalType('array', $message->requestHeaders); + $this->assertCount(2, $message->requestHeaders); + } + + public function testCanSendAnHttpMessage() + { + // Create a message to send + $message = new HTTPMessage('http://example.com', 'POST'); + + // Create a mock client and configure to be used for sending HTTP messages + $client = $this->createMock(Client::class); + $client->expects($this->once()) + ->method('send') + ->with($message) + ->willReturnCallback(function (HTTPMessage $message) { + return $message->ok = true; + }); + HTTPMessage::setHttpClient($client); + + // Send the message + $result = $message->send(); + + // Verify success + $this->assertTrue($result); + $this->assertTrue($message->ok); + } + + protected function setUp() + { + parent::setUp(); + + // Reset the HTTP client + HTTPMessage::setHttpClient(null); + } + + protected function tearDown() + { + parent::tearDown(); + + // Reset the HTTP client + HTTPMessage::setHttpClient(null); + } +}