├── src ├── HttpHandlerInterface.php ├── MiddlewareInterface.php ├── Mocks │ ├── MockResponseSequence.php │ ├── MockResponse.php │ ├── MockHttpClient.php │ ├── MockHttpRequestTrait.php │ ├── MockHttpHandler.php │ └── MockRequest.php ├── ProxyMiddleware.php ├── HttpResponseException.php ├── CurlHandler.php ├── HttpMessage.php ├── HttpRequest.php ├── HttpClient.php └── HttpResponse.php ├── composer.json ├── LICENSE.md ├── .github └── workflows │ └── ci.yml └── README.md /src/HttpHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2019 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http; 9 | 10 | /** 11 | * An interface for handling HttpRequests and turning them into HttpResponses. 12 | * 13 | */ 14 | interface HttpHandlerInterface { 15 | /** 16 | * Send the request. 17 | * 18 | * @param HttpRequest $request The request to send. 19 | * @return HttpResponse Returns the response corresponding to the request. 20 | */ 21 | public function send(HttpRequest $request): HttpResponse; 22 | } 23 | -------------------------------------------------------------------------------- /src/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | =8.0", 13 | "ext-curl": "*", 14 | "ext-json": "*", 15 | "vanilla/garden-utils": "^1.1", 16 | "psr/http-message": "^1.0", 17 | "slim/psr7": "^1.6" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "^9.0", 21 | "slim/slim": "^4.11", 22 | "slim/http": "^1.3" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Garden\\Http\\": "src" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Garden\\Http\\Tests\\": "tests" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | 4 | Copyright (c) 2014 Vanilla Forums Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/Mocks/MockResponseSequence.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2022 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http\Mocks; 9 | 10 | use Garden\Http\HttpResponse; 11 | 12 | /** 13 | * Holds onto an expected sequence of responses. 14 | */ 15 | class MockResponseSequence { 16 | 17 | /** @var HttpResponse[] */ 18 | private array $responseQueue = []; 19 | 20 | /** 21 | * @param HttpResponse[] $responseQueue 22 | */ 23 | public function __construct(array $responseQueue) { 24 | $this->responseQueue = $responseQueue; 25 | } 26 | 27 | /** 28 | * @param array|string|HttpResponse $response 29 | * @return $this 30 | */ 31 | public function push($response): self { 32 | if (!$response instanceof HttpResponse) { 33 | $response = MockResponse::json($response); 34 | } 35 | $this->responseQueue[] = $response; 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * Consume the next request from the queue. 42 | * 43 | * @return HttpResponse|null 44 | */ 45 | public function take(): ?HttpResponse { 46 | $response = array_shift($this->responseQueue); 47 | 48 | return $response; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Mocks/MockResponse.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2022 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http\Mocks; 9 | 10 | use Garden\Http\HttpResponse; 11 | 12 | /** 13 | * Simplified mock response. 14 | */ 15 | class MockResponse extends HttpResponse { 16 | 17 | /** 18 | * @return MockResponseSequence 19 | */ 20 | public static function sequence(): MockResponseSequence { 21 | return new MockResponseSequence([]); 22 | } 23 | 24 | /** 25 | * Mock an empty not found response. 26 | * 27 | * @return MockResponse 28 | */ 29 | public static function notFound(): MockResponse { 30 | return new MockResponse(404); 31 | } 32 | 33 | /** 34 | * Mock an empty success response. 35 | * 36 | * @return MockResponse 37 | */ 38 | public static function success(): MockResponse { 39 | return new MockResponse(200); 40 | } 41 | 42 | /** 43 | * Mock a successful json response. 44 | * 45 | * @param mixed $body 46 | * 47 | * @return MockResponse 48 | */ 49 | public static function json($body): MockResponse { 50 | return new MockResponse(200, ['content-type' => 'application/json'], json_encode($body)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Backend 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | concurrency: 10 | # Concurrency is only limited on pull requests. head_ref is only defined on PR triggers so otherwise it will use the random run id and always build all pushes. 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | phpunit-tests: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | php-version: ["8.0", "8.1", "8.2"] 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Installing PHP ${{ matrix.php-version }} 27 | uses: shivammathur/setup-php@v2 28 | with: 29 | php-version: ${{ matrix.php-version }} 30 | - name: Composer Install 31 | run: composer install -o 32 | - name: Start test server 33 | run: php -S 0.0.0.0:8091 ./tests/test-server.php > /dev/null 2>&1 & 34 | - name: Waiting for server to start 35 | run: sleep 10s 36 | - name: PHPUnit 37 | run: ./vendor/bin/phpunit -c ./tests/phpunit.xml.dist 38 | -------------------------------------------------------------------------------- /src/ProxyMiddleware.php: -------------------------------------------------------------------------------- 1 | alterRequest($request); 24 | return $next($request, $next); 25 | } 26 | 27 | /** 28 | * Given a url, try to replace it's base url so it routes with the cluster router. 29 | * 30 | * @param string $url 31 | * 32 | * @return void 33 | */ 34 | protected function alterRequest(HttpRequest $request): void 35 | { 36 | /** @var Uri $uri */ 37 | $originalUri = $request->getUri(); 38 | 39 | $uri = $originalUri; 40 | if ($this->downgradeScheme && $uri->getScheme() === "https") { 41 | $uri = $uri->withScheme("http"); 42 | } 43 | 44 | $requestUri = $uri->withHost($this->proxyHostname); 45 | 46 | $request->setUrl($requestUri); 47 | $request->setHeader("Host", $originalUri->getHost()); 48 | $request->setProxiedToUri($originalUri); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Mocks/MockHttpClient.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2022 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http\Mocks; 9 | 10 | use Garden\Http\HttpClient; 11 | use Garden\Http\HttpRequest; 12 | use Garden\Http\HttpResponse; 13 | 14 | /** 15 | * Mock HTTP client for testing. Does send actual HTTP requests. 16 | */ 17 | class MockHttpClient extends HttpClient 18 | { 19 | /** @var MockHttpHandler */ 20 | private $mockHandler; 21 | 22 | /** 23 | * @inheritdoc 24 | */ 25 | public function __construct(string $baseUrl = "") 26 | { 27 | parent::__construct($baseUrl); 28 | $this->mockHandler = new MockHttpHandler(); 29 | $this->setHandler($this->mockHandler); 30 | } 31 | 32 | /** 33 | * Add a mocked request/response combo. 34 | * 35 | * @param HttpRequest $request 36 | * @param HttpResponse $response 37 | * 38 | * @return $this 39 | */ 40 | public function addMockRequest(HttpRequest $request, HttpResponse $response) 41 | { 42 | $this->mockHandler->addMockRequest($request, $response); 43 | return $this; 44 | } 45 | 46 | /** 47 | * Add a single response to be queued up if a request is created. 48 | * 49 | * @param string $uri 50 | * @param HttpResponse $response 51 | * @param string $method 52 | * 53 | * @return $this 54 | * @deprecated Use addMockRequest() 55 | */ 56 | public function addMockResponse( 57 | string $uri, 58 | HttpResponse $response, 59 | string $method = HttpRequest::METHOD_GET 60 | ): static { 61 | $this->mockHandler->addMockResponse($uri, $response, $method); 62 | return $this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/HttpResponseException.php: -------------------------------------------------------------------------------- 1 | 5 | * @copyright 2009-2019 Vanilla Forums Inc. 6 | * @license MIT 7 | */ 8 | 9 | namespace Garden\Http; 10 | 11 | use Garden\Utils\ContextException; 12 | use Monolog\Utils; 13 | 14 | /** 15 | * An exception that occurs when there is a non 2xx response. 16 | */ 17 | class HttpResponseException extends ContextException implements \JsonSerializable { 18 | /** 19 | * @var HttpResponse 20 | */ 21 | private $response; 22 | 23 | /** 24 | * HttpException constructor. 25 | * 26 | * @param HttpResponse $response The response that generated this exception. 27 | * @param string $message The error message. 28 | */ 29 | public function __construct(HttpResponse $response, $message = "") { 30 | $responseJson = $response->jsonSerialize(); 31 | unset($responseJson['request']); 32 | $request = $response->getRequest(); 33 | $context = [ 34 | "response" => $responseJson, 35 | "request" => $request === null ? null : $request->jsonSerialize() 36 | ]; 37 | parent::__construct($message, $response->getStatusCode(), $context); 38 | $this->response = $response; 39 | } 40 | 41 | /** 42 | * Get the request that generated the exception. 43 | * 44 | * This is a convenience method that returns the request from the response property. 45 | * 46 | * @return HttpRequest 47 | */ 48 | public function getRequest(): HttpRequest { 49 | return $this->response->getRequest(); 50 | } 51 | 52 | /** 53 | * Get the response that generated the exception. 54 | * 55 | * @return HttpResponse Returns an HTTP response. 56 | */ 57 | public function getResponse(): HttpResponse { 58 | return $this->response; 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function jsonSerialize(): array { 65 | $result = [ 66 | 'message' => $this->getMessage(), 67 | 'status' => (int) $this->getHttpStatusCode(), 68 | 'class' => get_class($this), 69 | 'code' => (int) $this->getCode(), 70 | ] + $this->getContext(); 71 | return $result; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Mocks/MockHttpRequestTrait.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2022 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http\Mocks; 9 | 10 | use Garden\Http\HttpRequest; 11 | use Garden\Http\HttpResponse; 12 | 13 | /** 14 | * Trait for mocking HTTP responses. 15 | */ 16 | trait MockHttpRequestTrait { 17 | 18 | /** @var MockRequest[] */ 19 | protected $mockRequests = []; 20 | 21 | /** 22 | * Return the best matching response, if any. 23 | * 24 | * @param HttpRequest $request 25 | * @return HttpResponse 26 | */ 27 | protected function dispatchMockRequest(HttpRequest $request): HttpResponse { 28 | $matchedMocks = []; 29 | foreach ($this->mockRequests as $mockRequest) { 30 | if ($mockRequest->match($request)) { 31 | $matchedMocks[] = $mockRequest; 32 | } 33 | } 34 | 35 | // Sort the matches in descending order. 36 | usort($matchedMocks, function (MockRequest $a, MockRequest $b) { 37 | return $b->getScore() <=> $a->getScore(); 38 | }); 39 | 40 | $bestMock = $matchedMocks[0] ?? null; 41 | 42 | 43 | if ($bestMock == null) { 44 | $response = new HttpResponse(404); 45 | } else { 46 | $response = $bestMock->getResponse($request); 47 | } 48 | 49 | $response->setRequest($request); 50 | $request->setResponse($response); 51 | $this->history[] = $request; 52 | return $response; 53 | } 54 | 55 | /** 56 | * Mock multiple requests at once. 57 | * 58 | * @example 59 | * $this->multi([ 60 | * "/some/url" => ["message" => "this is a response"], 61 | * "GET https://url.here/*" => MockResponse::sequence() 62 | * ->push("response1") 63 | * ->push(new HttpResponse(500, ["headers"], "body"), 64 | * "*" => MockResponse::notFound(), 65 | * ]); 66 | * 67 | * @param array $toMock 68 | * 69 | * @return $this 70 | */ 71 | public function mockMulti(array $toMock): self { 72 | foreach ($toMock as $url => $response) { 73 | $this->addMockRequest($url, $response); 74 | } 75 | return $this; 76 | } 77 | 78 | /** 79 | * Add a mocked request/response combo. 80 | * 81 | * @param HttpRequest|string $request 82 | * @param HttpResponse|MockResponseSequence|array $response 83 | * @return $this 84 | */ 85 | public function addMockRequest($request, $response) { 86 | $this->mockRequests[] = new MockRequest($request, $response); 87 | return $this; 88 | } 89 | 90 | /** 91 | * Add a single response to be queued up if a request is created. 92 | * 93 | * @param string $uri 94 | * @param HttpResponse $response 95 | * @param string $method 96 | * 97 | * @return $this 98 | * @deprecated Use addMockRequest() 99 | */ 100 | public function addMockResponse( 101 | string $uri, 102 | HttpResponse $response, 103 | string $method = HttpRequest::METHOD_GET 104 | ) { 105 | $this->addMockRequest( 106 | new HttpRequest($method, $uri), 107 | $response 108 | ); 109 | return $this; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Mocks/MockHttpHandler.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2022 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http\Mocks; 9 | 10 | use Garden\Http\HttpHandlerInterface; 11 | use Garden\Http\HttpRequest; 12 | use Garden\Http\HttpResponse; 13 | use PHPUnit\Framework\Assert; 14 | 15 | /** 16 | * Handler for mock http requests. Never makes any actual network requests. 17 | */ 18 | class MockHttpHandler implements HttpHandlerInterface { 19 | 20 | use MockHttpRequestTrait; 21 | 22 | /** @var MockHttpHandler|null */ 23 | public static ?MockHttpHandler $mock = null; 24 | 25 | /** @var array */ 26 | private array $history = []; 27 | 28 | /** 29 | * @inheritDoc 30 | */ 31 | public function send(HttpRequest $request): HttpResponse { 32 | $response = $this->dispatchMockRequest($request); 33 | $response->setRequest($request); 34 | $request->setResponse($response); 35 | 36 | return $response; 37 | } 38 | 39 | /** 40 | * Mock all incoming network requests to garden-http. 41 | * 42 | * @return MockHttpHandler The mocked handler. 43 | */ 44 | public static function mock(): MockHttpHandler { 45 | if (self::$mock === null) { 46 | self::$mock = new MockHttpHandler(); 47 | } 48 | 49 | return self::$mock; 50 | } 51 | 52 | /** 53 | * @return MockHttpHandler|null 54 | */ 55 | public static function getMock(): ?MockHttpHandler { 56 | return self::$mock; 57 | } 58 | 59 | /** 60 | * Clear the mock and allow normal requests to take place. 61 | * 62 | * @return void 63 | */ 64 | public static function clearMock(): void { 65 | self::$mock = null; 66 | } 67 | 68 | /** 69 | * Reset the mocked requests/responses. 70 | * 71 | * @return MockHttpHandler 72 | */ 73 | public function reset(): MockHttpHandler { 74 | $this->history = []; 75 | $this->mockRequests = []; 76 | self::$mock = new MockHttpHandler(); 77 | 78 | return self::$mock; 79 | } 80 | 81 | /** 82 | * Assert that no requests were sent. 83 | * 84 | * @return void 85 | */ 86 | public function assertNothingSent(): void { 87 | $historyIDs = $this->getHistoryIDs(); 88 | Assert::assertEmpty($this->history, "Expected no requests to be sent. Instead received " . count($historyIDs) . ".\n" . implode("\n", $historyIDs)); 89 | } 90 | 91 | /** 92 | * Assert that a request was sent that matches a callable. 93 | * 94 | * @param callable(HttpRequest $request): bool $matcher 95 | * 96 | * @return HttpRequest 97 | */ 98 | public function assertSent(callable $matcher): HttpRequest { 99 | $matchingRequest = null; 100 | foreach ($this->history as $request) { 101 | $result = call_user_func($matcher, $request); 102 | if ($result) { 103 | $matchingRequest = $request; 104 | break; 105 | } 106 | } 107 | 108 | $historyIDs = $this->getHistoryIDs(); 109 | if (empty($historyIDs)) { 110 | Assert::fail("Expected to find a matching request. Instead no requests were sent."); 111 | } 112 | Assert::assertInstanceOf(HttpRequest::class, $matchingRequest, "Expected to find a matching request. Instead there were " . count($historyIDs) . " requests that did not match.\n" . implode("\n", $historyIDs)); 113 | 114 | return $matchingRequest; 115 | } 116 | 117 | /** 118 | * Assert that a request was not sent that matches a callable. 119 | * 120 | * @param callable(HttpRequest $request): bool $matcher 121 | */ 122 | public function assertNotSent(callable $matcher): void { 123 | $matchingRequest = null; 124 | foreach ($this->history as $request) { 125 | $result = call_user_func($matcher, $request); 126 | if ($result) { 127 | $matchingRequest = $request; 128 | break; 129 | } 130 | } 131 | 132 | if ($matchingRequest instanceof HttpRequest) { 133 | Assert::fail("Expected to find no matching request but instead a matching request \"{$request->getMethod()}{$request->getUrl()}\" was found."); 134 | } 135 | } 136 | 137 | /** 138 | * @return string[] 139 | */ 140 | private function getHistoryIDs(): array { 141 | $historyIDs = []; 142 | foreach ($this->history as $historyItem) { 143 | $historyIDs[] = "{$historyItem->getMethod()} {$historyItem->getUrl()}"; 144 | } 145 | 146 | return $historyIDs; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Mocks/MockRequest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2022 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http\Mocks; 9 | 10 | use Garden\Http\HttpRequest; 11 | use Garden\Http\HttpResponse; 12 | 13 | /** 14 | * Request object to hold an expected response for a mock request. 15 | */ 16 | class MockRequest { 17 | 18 | /** @var HttpRequest */ 19 | private $request; 20 | 21 | /** @var HttpResponse|MockResponseSequence */ 22 | private $response; 23 | 24 | /** @var int */ 25 | private $score = 0; 26 | 27 | /** 28 | * DI. 29 | * 30 | * @param HttpRequest|string $request 31 | * @param HttpResponse|string|array|null|callable(HttpRequest): HttpResponse $response 32 | */ 33 | public function __construct($request, $response = null) { 34 | if ($request === "*") { 35 | $request = new HttpRequest(HttpRequest::METHOD_GET, "https://*/*"); 36 | } 37 | if (is_string($request)) { 38 | $pieces = explode(" ", $request); 39 | $method = count($pieces) === 1 ? HttpRequest::METHOD_GET : $pieces[0]; 40 | $url = count($pieces) === 1 ? $pieces[0] : $pieces[1]; 41 | 42 | $request = new HttpRequest($method, $url); 43 | } 44 | 45 | $ownUrlParts = parse_url($request->getUrl()); 46 | if (empty($ownUrlParts['host'])) { 47 | // Add a wildcard. 48 | $request->setUrl("https://*" . $request->getUrl()); 49 | } 50 | 51 | $response = $response ?? MockResponse::success(); 52 | 53 | if (!is_callable($response) && !$response instanceof MockResponseSequence && !$response instanceof HttpResponse) { 54 | $response = MockResponse::json($response); 55 | } 56 | 57 | $this->request = $request; 58 | $this->response = $response; 59 | } 60 | 61 | /** 62 | * Determine how closely another request matches or mocked one. 63 | * 64 | * @param HttpRequest $incomingRequest The incoming request. 65 | * 66 | * @return bool True if the incoming request matched. 67 | */ 68 | public function match(HttpRequest $incomingRequest): bool { 69 | $score = 0; 70 | if ($incomingRequest->getMethod() !== $this->request->getMethod()) { 71 | // Wrong method. No match. 72 | $this->setScore(0); 73 | 74 | return false; 75 | } 76 | 77 | $incomingUrlParts = parse_url($incomingRequest->getUrl()); 78 | $ownUrlParts = parse_url($this->request->getUrl()); 79 | $compareUrls = function (string $own, string $incoming) use (&$score): bool { 80 | if (str_contains($own, "*") && fnmatch($own, $incoming) || $incoming == "" && ($own === "*" || $own === "/*")) { 81 | $score += 1; 82 | 83 | return true; 84 | } elseif ($own == $incoming) { 85 | $score += 2; 86 | 87 | return true; 88 | } else { 89 | return false; 90 | } 91 | }; 92 | 93 | if (!$compareUrls($ownUrlParts['host'] ?? "", $incomingUrlParts['host'] ?? "")) { 94 | // Wrong host. No match. 95 | $this->setScore(0); 96 | 97 | return false; 98 | } 99 | 100 | if (isset($ownUrlParts['path']) && !$compareUrls($ownUrlParts['path'] ?? "", $incomingUrlParts['path'] ?? "")) { 101 | // Wrong path. No match. 102 | $this->setScore(0); 103 | 104 | return false; 105 | } 106 | 107 | parse_str($incomingUrlParts['query'] ?? '', $incomingQuery); 108 | parse_str($ownUrlParts['query'] ?? '', $ownQuery); 109 | 110 | $compareArrays = function (array $own, array $incoming) use (&$score): bool { 111 | foreach ($own as $ownParam => $ownValue) { 112 | if (!isset($incoming[$ownParam]) || $incoming[$ownParam] != $ownValue) { 113 | // The mock specified a query, and it wasn't present or did not match the incoming one. 114 | // Both request specified the parameter, but it didn't match. 115 | $this->setScore(0); 116 | 117 | return false; 118 | } 119 | 120 | // we had a match, increment score. 121 | $score += 1; 122 | } 123 | 124 | return true; 125 | }; 126 | 127 | if (!$compareArrays($ownQuery, $incomingQuery)) { 128 | return false; 129 | } 130 | 131 | $incomingBody = $incomingRequest->getBody(); 132 | $ownBody = $incomingRequest->getBody(); 133 | if (is_array($ownBody) && is_array($incomingBody)) { 134 | if (!$compareArrays($ownBody, $incomingBody)) { 135 | return false; 136 | } 137 | } 138 | 139 | $this->setScore($score); 140 | 141 | return true; 142 | } 143 | 144 | /** 145 | * @return HttpRequest 146 | */ 147 | public function getRequest(): HttpRequest { 148 | return $this->request; 149 | } 150 | 151 | /** 152 | * Get the expected response from the mock. 153 | * 154 | * @param HttpRequest $forRequest The request dispatched. 155 | * 156 | * @return HttpResponse 157 | */ 158 | public function getResponse(HttpRequest $forRequest): HttpResponse { 159 | $response = $this->response; 160 | if (is_callable($response)) { 161 | return call_user_func($response, $forRequest); 162 | } elseif ($response instanceof MockResponseSequence) { 163 | return $response->take() ?? MockResponse::notFound(); 164 | } else { 165 | return $this->response; 166 | } 167 | } 168 | 169 | /** 170 | * @return int 171 | */ 172 | public function getScore(): int { 173 | return $this->score; 174 | } 175 | 176 | /** 177 | * @param int $score 178 | */ 179 | public function setScore(int $score): void { 180 | $this->score = $score; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/CurlHandler.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2019 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http; 9 | 10 | /** 11 | * A handler that uses cURL to send the request. 12 | */ 13 | class CurlHandler implements HttpHandlerInterface { 14 | /** 15 | * @var resource|\CurlHandle cURL handle to be (re)used 16 | */ 17 | protected $curl = null; 18 | 19 | /** 20 | * Gets a cURL resource for this request 21 | * 22 | * @param HttpRequest $request 23 | * @return resource cURL handle 24 | */ 25 | protected function getCurlHandle(HttpRequest $request) { 26 | if ($this->curl === null) { 27 | $this->curl = curl_init(); 28 | } elseif (!$request->hasHeader('connection') || strcasecmp($request->getHeader('connection'), 'close') === 0) { 29 | // we have a reusable curl instance, but this request doesn't want to be shared. Close it and make a new one 30 | curl_close($this->curl); 31 | $this->curl = curl_init(); 32 | } 33 | 34 | return $this->curl; 35 | } 36 | 37 | /** 38 | * Create the cURL resource that represents this request. 39 | * 40 | * @param HttpRequest $request The request to create the cURL resource for. 41 | * @return resource Returns the cURL resource. 42 | * @see curl_init(), curl_setopt(), curl_exec() 43 | */ 44 | protected function createCurl(HttpRequest $request) { 45 | $ch = $this->getCurlHandle($request); 46 | 47 | // Add the body first so we can calculate a content length. 48 | $body = ''; 49 | if ($request->getMethod() === HttpRequest::METHOD_HEAD) { 50 | curl_setopt($ch, CURLOPT_NOBODY, true); 51 | } elseif ($request->getMethod() !== HttpRequest::METHOD_GET) { 52 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $request->getMethod()); 53 | 54 | $body = $this->makeCurlBody($request); 55 | if ($body) { 56 | curl_setopt($ch, CURLOPT_POSTFIELDS, $body); 57 | } 58 | } 59 | 60 | // Encode the headers. 61 | $headers = []; 62 | foreach ($request->getHeaders() as $key => $values) { 63 | foreach ($values as $line) { 64 | $headers[] = "$key: $line"; 65 | } 66 | } 67 | 68 | if (is_string($body) && !$request->hasHeader('Content-Length')) { 69 | $headers[] = 'Content-Length: '.strlen($body); 70 | } 71 | 72 | if (!$request->hasHeader('Expect')) { 73 | $headers[] = 'Expect:'; 74 | } 75 | 76 | curl_setopt( 77 | $ch, 78 | CURLOPT_HTTP_VERSION, 79 | $request->getProtocolVersion() == '1.0' ? CURL_HTTP_VERSION_1_0 : CURL_HTTP_VERSION_1_1 80 | ); 81 | 82 | curl_setopt($ch, CURLOPT_URL, $request->getUrl()); 83 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 84 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 85 | curl_setopt($ch, CURLOPT_TIMEOUT, $request->getTimeout()); 86 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $request->getConnectTimeout()); 87 | curl_setopt($ch, CURLOPT_MAXREDIRS, 10); 88 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 89 | curl_setopt($ch, CURLOPT_HEADER, true); 90 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $request->getVerifyPeer()); 91 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $request->getVerifyPeer() ? 2 : 0); 92 | curl_setopt($ch, CURLOPT_ENCODING, ''); //"utf-8"); 93 | curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); 94 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 95 | curl_setopt($ch, CURLOPT_HEADER, true); 96 | 97 | if (!empty($request->getAuth())) { 98 | curl_setopt($ch, CURLOPT_USERPWD, $request->getAuth()[0].":".((empty($request->getAuth()[1])) ? "" : $request->getAuth()[1])); 99 | } 100 | 101 | return $ch; 102 | } 103 | 104 | /** 105 | * Convert the request body into a format suitable to be passed to curl. 106 | * 107 | * @param HttpRequest $request The request to turn into the cURL body. 108 | * @return string|array Returns the curl body. 109 | */ 110 | protected function makeCurlBody(HttpRequest $request) { 111 | $body = $request->getBody(); 112 | 113 | if (is_string($body)) { 114 | return (string)$body; 115 | } 116 | 117 | $contentType = $request->getHeader('Content-Type'); 118 | if (stripos($contentType, 'application/json') === 0) { 119 | $body = json_encode($body); 120 | } 121 | 122 | return $body; 123 | } 124 | 125 | /** 126 | * Execute a curl handle. 127 | * 128 | * This method just calls `curl_exec()` and returns the result. It is meant to stay this way for easier subclassing. 129 | * 130 | * @param resource $ch The curl handle to execute. 131 | * @return HttpResponse Returns an {@link RestResponse} object with the information from the request 132 | */ 133 | protected function execCurl($ch) { 134 | $response = curl_exec($ch); 135 | 136 | return $response; 137 | } 138 | 139 | /** 140 | * Closes the cURL handle. 141 | */ 142 | public function closeConnection() { 143 | if ($this->curl) { 144 | curl_close($this->curl); 145 | } 146 | 147 | $this->curl = null; 148 | } 149 | 150 | /** 151 | * Decode a curl response and turn it into 152 | * 153 | * @param $ch 154 | * @param $response 155 | * @return HttpResponse 156 | */ 157 | protected function decodeCurlResponse($ch, $response): HttpResponse { 158 | // Split the full response into its headers and body 159 | $info = curl_getinfo($ch); 160 | $code = $info["http_code"]; 161 | if ($response) { 162 | $header_size = $info["header_size"]; 163 | $rawHeaders = substr($response, 0, $header_size); 164 | $status = null; 165 | $rawBody = substr($response, $header_size); 166 | } else { 167 | $status = $code; 168 | $rawHeaders = []; 169 | $rawBody = curl_error($ch); 170 | } 171 | 172 | $result = new HttpResponse($status, $rawHeaders, $rawBody); 173 | 174 | return $result; 175 | } 176 | 177 | /** 178 | * {@inheritdoc} 179 | */ 180 | public function send(HttpRequest $request): HttpResponse { 181 | $ch = $this->createCurl($request); 182 | $curlResponse = $this->execCurl($ch); 183 | $response = $this->decodeCurlResponse($ch, $curlResponse); 184 | $response->setRequest($request); 185 | $request->setResponse($response); 186 | 187 | if (!$request->hasHeader('connection') 188 | || strcasecmp($request->getHeader('connection'), 'close') === 0 189 | || strcasecmp($response->getHeader('connection'), 'close') === 0 190 | ) { 191 | $this->closeConnection(); 192 | } 193 | 194 | return $response; 195 | } 196 | 197 | /** 198 | * Destroy any existing cURL handlers when the object is destroyed 199 | */ 200 | public function __destruct() { 201 | if ($this->curl) { 202 | curl_close($this->curl); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/HttpMessage.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2019 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http; 9 | 10 | 11 | use Psr\Http\Message\MessageInterface; 12 | use Psr\Http\Message\StreamInterface; 13 | 14 | /** 15 | * HTTP messages consist of requests from a client to a server and responses from a server to a client. 16 | * 17 | * This is the base class for both the {@link HttpRequest} class and the {@link HttpResponse} class. 18 | */ 19 | abstract class HttpMessage implements MessageInterface { 20 | /// Properties /// 21 | 22 | /** 23 | * @var string|array The body of the message. 24 | */ 25 | protected $body; 26 | 27 | /** 28 | * @var string The HTTP protocol version of the request. 29 | */ 30 | protected $protocolVersion = '1.1'; 31 | 32 | /** 33 | * @var array An array of headers stored by lower cased header name. 34 | */ 35 | private $headers = []; 36 | 37 | /** 38 | * @var array An array of header names as specified by the various header methods. 39 | */ 40 | private $headerNames = []; 41 | 42 | /// Methods /// 43 | 44 | /** 45 | * Adds a new header with the given value. 46 | * 47 | * If an existing header exists with the given name then the value will be appended to the end of the list. 48 | * 49 | * @param string $name The name of the header. 50 | * @param string $value The value of the header. 51 | * @return HttpMessage $this Returns `$this` for fluent calls. 52 | */ 53 | public function addHeader(string $name, string $value) { 54 | $key = strtolower($name); 55 | $this->headerNames[$key] = $name; 56 | $this->headers[$key][] = $value; 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * Retrieves a header by the given case-insensitive name, as a string. 63 | * 64 | * This method returns all of the header values of the given 65 | * case-insensitive header name as a string concatenated together using 66 | * a comma. 67 | * 68 | * NOTE: Not all header values may be appropriately represented using 69 | * comma concatenation. For such headers, use getHeaderLines() instead 70 | * and supply your own delimiter when concatenating. 71 | * 72 | * @param string $name Case-insensitive header field name. 73 | * @return string 74 | */ 75 | public function getHeader($name): string { 76 | $lines = $this->getHeaderLines($name); 77 | return implode(',', $lines); 78 | } 79 | 80 | /** 81 | * Retrieves a header by the given case-insensitive name as an array of strings. 82 | * 83 | * @param string $name Case-insensitive header field name. 84 | * @return string[] 85 | */ 86 | public function getHeaderLines(string $name): array { 87 | $key = strtolower($name); 88 | $result = isset($this->headers[$key]) ? $this->headers[$key] : []; 89 | return $result; 90 | } 91 | 92 | /** 93 | * Retrieves all message headers. 94 | * 95 | * The keys represent the header name as it will be sent over the wire, and 96 | * each value is an array of strings associated with the header. 97 | * 98 | * While header names are not case-sensitive, getHeaders() will preserve the 99 | * exact case in which headers were originally specified. 100 | * 101 | * @return array Returns an associative array of the message's headers. 102 | * Each key is a header name, and each value is an an array of strings. 103 | */ 104 | public function getHeaders(): array { 105 | $result = []; 106 | 107 | foreach ($this->headers as $key => $lines) { 108 | $name = $this->headerNames[$key]; 109 | $result[$name] = $lines; 110 | } 111 | 112 | return $result; 113 | } 114 | 115 | /** 116 | * Set all of the headers. This will overwrite any existing headers. 117 | * 118 | * @param array|string $headers An array or string of headers to set. 119 | * 120 | * The array of headers can be in the following form: 121 | * 122 | * - ["Header-Name" => "value", ...] 123 | * - ["Header-Name" => ["lines, ...], ...] 124 | * - ["Header-Name: value", ...] 125 | * - Any combination of the above formats. 126 | * 127 | * A header string is the the form of the HTTP standard where each Key: Value pair is separated by `\r\n`. 128 | * 129 | * @return HttpMessage Returns `$this` for fluent calls. 130 | */ 131 | public function setHeaders($headers) { 132 | $this->headers = []; 133 | $this->headerNames = []; 134 | 135 | $headers = $this->parseHeaders($headers); 136 | foreach ($headers as $name => $lines) { 137 | $key = strtolower($name); 138 | if (isset($this->headers[$key])) { 139 | $this->headers[$key] = array_merge($this->headers[$key], $lines); 140 | } else { 141 | $this->headers[$key] = $lines; 142 | } 143 | $this->headerNames[$key] = $name; 144 | } 145 | 146 | return $this; 147 | } 148 | 149 | /** 150 | * Parse the http response headers from a response. 151 | * 152 | * @param string|array $headers Either the header string from a curl response or an array of header lines. 153 | * @return array 154 | */ 155 | private function parseHeaders($headers): array { 156 | if (is_string($headers)) { 157 | $headers = explode("\r\n", $headers); 158 | } 159 | 160 | if (empty($headers)) { 161 | return []; 162 | } 163 | 164 | $result = []; 165 | foreach ($headers as $key => $line) { 166 | if (is_numeric($key)) { 167 | if (strpos($line, 'HTTP/') === 0) { 168 | // Strip the status line and restart. 169 | $result = []; 170 | continue; 171 | } elseif (strstr($line, ': ')) { 172 | [$key, $line] = explode(': ', $line); 173 | } else { 174 | continue; 175 | } 176 | } 177 | if (is_array($line)) { 178 | $result[$key] = array_merge($result[$key] ?? [], $line); 179 | } else { 180 | $result[$key][] = $line; 181 | } 182 | } 183 | 184 | return $result; 185 | } 186 | 187 | /** 188 | * Checks if a header exists by the given case-insensitive name. 189 | * 190 | * @param string $header Case-insensitive header name. 191 | * @return bool Returns true if any header names match the given header 192 | * name using a case-insensitive string comparison. Returns false if 193 | * no matching header name is found in the message. 194 | */ 195 | public function hasHeader($header): bool { 196 | return !empty($this->headers[strtolower($header)]); 197 | } 198 | 199 | /** 200 | * Set a header by case-insensitive name. Setting a header will overwrite the current value for the header. 201 | * 202 | * @param string $name The name of the header. 203 | * @param string|string[]|null $value The value of the new header. Pass `null` to remove the header. 204 | * @return HttpMessage Returns $this for fluent calls. 205 | */ 206 | public function setHeader(string $name, $value) { 207 | $key = strtolower($name); 208 | 209 | if ($value === null) { 210 | unset($this->headerNames[$key], $this->headers[$key]); 211 | } else { 212 | $this->headerNames[$key] = $name; 213 | $this->headers[$key] = (array)$value; 214 | } 215 | 216 | return $this; 217 | } 218 | 219 | /** 220 | * Get the HTTP protocol version of the message. 221 | * 222 | * @return string Returns the current HTTP protocol version. 223 | */ 224 | public function getProtocolVersion(): string { 225 | return $this->protocolVersion; 226 | } 227 | 228 | /** 229 | * Set the HTTP protocol version. 230 | * 231 | * The default protocol version of all messages is HTTP 1.1. Some old servers may only support HTTP 1.0 so that can 232 | * be overridden with this method. 233 | * 234 | * @param string $protocolVersion The new protocol version to set. 235 | * @return HttpMessage Returns `$this` for fluent calls. 236 | */ 237 | public function setProtocolVersion(string $protocolVersion) { 238 | $this->protocolVersion = $protocolVersion; 239 | return $this; 240 | } 241 | 242 | /** 243 | * Get the body of the message. 244 | * 245 | * @return string|array Returns the body. 246 | */ 247 | public function getBody() { 248 | return $this->body; 249 | } 250 | 251 | /** 252 | * Set the body of the message. 253 | * 254 | * @param string|array $body The new body of the message. 255 | * @return HttpMessage Returns `$this` for fluent calls. 256 | */ 257 | public function setBody($body) { 258 | $this->body = $body; 259 | return $this; 260 | } 261 | 262 | /** 263 | * @inheritDoc 264 | */ 265 | public function withProtocolVersion($version): self { 266 | $cloned = clone $this; 267 | $cloned->protocolVersion = $version; 268 | return $cloned; 269 | } 270 | 271 | /** 272 | * @inheritDoc 273 | */ 274 | public function getHeaderLine($name) { 275 | $headerLines = $this->getHeaderLines($name); 276 | return implode(",", $headerLines); 277 | } 278 | 279 | /** 280 | * @inheritDoc 281 | */ 282 | public function withHeader($name, $value) { 283 | $cloned = clone $this; 284 | $cloned->setHeader($name, $value); 285 | return $cloned; 286 | } 287 | 288 | /** 289 | * @inheritDoc 290 | */ 291 | public function withAddedHeader($name, $value) { 292 | $cloned = clone $this; 293 | $cloned->addHeader($name, $value); 294 | return $cloned; 295 | } 296 | 297 | /** 298 | * @inheritDoc 299 | */ 300 | public function withoutHeader($name) { 301 | $cloned = clone $this; 302 | unset($cloned->headers[$name]); 303 | unset($cloned->headerNames[$name]); 304 | return $cloned; 305 | } 306 | 307 | /** 308 | * @inheritDoc 309 | */ 310 | public function withBody(StreamInterface $body) { 311 | $cloned = clone $this; 312 | $cloned->setBody($body->getContents()); 313 | return $cloned; 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/HttpRequest.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2019 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http; 9 | 10 | use Garden\Http\Mocks\MockHttpHandler; 11 | use Psr\Http\Message\RequestInterface; 12 | use Psr\Http\Message\UriInterface; 13 | use Slim\Psr7\Factory\UriFactory; 14 | 15 | /** 16 | * Representation of an outgoing, client-side request. 17 | */ 18 | class HttpRequest extends HttpMessage implements \JsonSerializable, RequestInterface { 19 | /// Constants /// 20 | 21 | public const OPT_TIMEOUT = "timeout"; 22 | public const OPT_CONNECT_TIMEOUT = "connectTimeout"; 23 | public const OPT_VERIFY_PEER = "verifyPeer"; 24 | public const OPT_PROTOCOL_VERSION = "protocolVersion"; 25 | public const OPT_AUTH = "auth"; 26 | 27 | const METHOD_DELETE = 'DELETE'; 28 | const METHOD_GET = 'GET'; 29 | const METHOD_HEAD = 'HEAD'; 30 | const METHOD_OPTIONS = 'OPTIONS'; 31 | const METHOD_PATCH = 'PATCH'; 32 | const METHOD_POST = 'POST'; 33 | const METHOD_PUT = 'PUT'; 34 | 35 | /// Properties /// 36 | 37 | /** 38 | * @var string The HTTP method of the request. 39 | */ 40 | protected $method; 41 | 42 | /** 43 | * @var string The URL of the request. 44 | * @deprecated Use $uri instead. 45 | */ 46 | protected $url; 47 | 48 | /** @var UriInterface */ 49 | protected UriInterface $uri; 50 | 51 | /** @var UriInterface|null */ 52 | protected UriInterface|null $proxiedToUri = null; 53 | 54 | /** 55 | * @var array 56 | */ 57 | protected $auth; 58 | 59 | /** 60 | * @var int 61 | */ 62 | protected $timeout = 0; 63 | 64 | /** @var int */ 65 | protected $connectTimeout = 0; 66 | 67 | /** 68 | * @var bool 69 | */ 70 | protected $verifyPeer; 71 | 72 | /** @var HttpResponse|null */ 73 | protected ?HttpResponse $response; 74 | 75 | /// Methods /// 76 | 77 | /** 78 | * Initialize an instance of the {@link HttpRequest} class. 79 | * 80 | * @param string $method The HTTP method of the request. 81 | * @param string|UriInterface $url The URL where the request will be sent. 82 | * @param string|array $body The body of the request. 83 | * @param array $headers An array of http headers to be sent with the request. 84 | * @param array $options An array of extra options. 85 | * 86 | * - protocolVersion: The HTTP protocol version. 87 | * - verifyPeer: Whether or not to verify an SSL peer. Default true. 88 | * - auth: A username/password used to send basic HTTP authentication with the request. 89 | * - timeout: The number of seconds to wait before the request times out. A value of zero means no timeout. 90 | */ 91 | public function __construct(string $method = self::METHOD_GET, string|UriInterface $url = '', $body = '', array $headers = [], array $options = []) { 92 | $this->setMethod(strtoupper($method)); 93 | if ($url instanceof UriInterface) { 94 | $this->setUri($url); 95 | } else { 96 | $this->setUrl($url); 97 | } 98 | $this->setBody($body); 99 | $this->setHeaders($headers); 100 | 101 | $this->setProtocolVersion($options[self::OPT_PROTOCOL_VERSION] ?? "1.1"); 102 | $this->setAuth($options[self::OPT_AUTH] ?? []); 103 | $this->setVerifyPeer($options[self::OPT_VERIFY_PEER] ?? true); 104 | $this->setConnectTimeout($options[self::OPT_CONNECT_TIMEOUT] ?? 0); 105 | $this->setTimeout($options[self::OPT_TIMEOUT] ?? 0); 106 | } 107 | 108 | /** 109 | * @return HttpResponse|null 110 | */ 111 | public function getResponse(): ?HttpResponse { 112 | return $this->response; 113 | } 114 | 115 | /** 116 | * @param HttpResponse $response 117 | * @return void 118 | */ 119 | public function setResponse(HttpResponse $response): void { 120 | $this->response = $response; 121 | } 122 | 123 | /** 124 | * Get the auth. 125 | * 126 | * @return array Returns the auth. 127 | */ 128 | public function getAuth(): array { 129 | return $this->auth; 130 | } 131 | 132 | /** 133 | * Set the basic HTTP authentication for the request. 134 | * 135 | * @param array $auth An array in the form `[username, password]`. 136 | * @return HttpRequest Returns `$this` for fluent calls. 137 | */ 138 | public function setAuth(array $auth) { 139 | $this->auth = $auth; 140 | return $this; 141 | } 142 | 143 | /** 144 | * Send the request. 145 | * 146 | * @return HttpResponse Returns the response from the API. 147 | */ 148 | public function send(HttpHandlerInterface $executor = null): HttpResponse { 149 | if ($executor === null) { 150 | $executor = new CurlHandler(); 151 | } 152 | 153 | if (MockHttpHandler::getMock() !== null) { 154 | $executor = MockHttpHandler::getMock(); 155 | } 156 | 157 | $response = $executor->send($this); 158 | 159 | return $response; 160 | } 161 | 162 | /** 163 | * Get the HTTP method. 164 | * 165 | * @return string Returns the HTTP method. 166 | */ 167 | public function getMethod(): string { 168 | return $this->method; 169 | } 170 | 171 | /** 172 | * Set the HTTP method. 173 | * 174 | * @param string $method The new HTTP method. 175 | * @return HttpRequest Returns `$this` for fluent calls. 176 | */ 177 | public function setMethod(string $method) { 178 | $this->method = strtoupper($method); 179 | return $this; 180 | } 181 | 182 | /** 183 | * Get the URL where the request will be sent. 184 | * 185 | * @return string Returns the URL. 186 | */ 187 | public function getUrl(): string { 188 | return $this->url; 189 | } 190 | 191 | /** 192 | * Set the URL where the request will be sent. 193 | * 194 | * @param string $url The new URL. 195 | * @return HttpRequest Returns `$this` for fluent calls. 196 | */ 197 | public function setUrl(string $url) { 198 | $uriFactory = new UriFactory(); 199 | $uri = $uriFactory->createUri($url); 200 | $this->url = $url; 201 | $this->uri = $uri; 202 | return $this; 203 | } 204 | 205 | /** 206 | * Set the URI of the request. 207 | * 208 | * @param UriInterface $uri The new URI. 209 | * 210 | * @return static Returns `$this` for fluent calls. 211 | */ 212 | public function setUri(UriInterface $uri): static { 213 | $this->uri = $uri; 214 | $this->url = (string) $uri; 215 | return $this; 216 | } 217 | 218 | /** 219 | * Get whether or not to verify the SSL certificate of HTTPS calls. 220 | * 221 | * In production this settings should always be set to `true`, but during development or testing it's sometimes 222 | * necessary to allow invalid SSL certificates. 223 | * 224 | * @return boolean Returns the verifyPeer. 225 | */ 226 | public function getVerifyPeer(): bool { 227 | return $this->verifyPeer; 228 | } 229 | 230 | /** 231 | * Set whether or not to verify the SSL certificate of HTTPS calls. 232 | * 233 | * @param bool $verifyPeer The new verify peer setting. 234 | * @return HttpRequest Returns `$this` for fluent calls. 235 | */ 236 | public function setVerifyPeer(bool $verifyPeer) { 237 | $this->verifyPeer = $verifyPeer; 238 | return $this; 239 | } 240 | 241 | /** 242 | * Get the request timeout. 243 | * 244 | * @return int Returns the timeout in seconds. 245 | */ 246 | public function getTimeout(): int { 247 | return $this->timeout; 248 | } 249 | 250 | /** 251 | * Set the request timeout. 252 | * 253 | * @param int $timeout The new request timeout in seconds. 254 | * @return HttpRequest Returns `$this` for fluent calls. 255 | */ 256 | public function setTimeout(int $timeout) { 257 | $this->timeout = $timeout; 258 | return $this; 259 | } 260 | 261 | /** 262 | * @return int 263 | */ 264 | public function getConnectTimeout(): int { 265 | return $this->connectTimeout; 266 | } 267 | 268 | /** 269 | * @param int $connectTimeout 270 | * @return void 271 | */ 272 | public function setConnectTimeout(int $connectTimeout): HttpRequest { 273 | $this->connectTimeout = $connectTimeout; 274 | return $this; 275 | } 276 | 277 | /** 278 | * Get constructor options as an array. 279 | * 280 | * This method is useful for copying requests as it can be passed into constructors. 281 | * 282 | * @return array Returns an array of options. 283 | */ 284 | public function getOptions(): array { 285 | return [ 286 | 'protocolVersion' => $this->getProtocolVersion(), 287 | 'auth' => $this->getAuth(), 288 | 'timeout' => $this->getTimeout(), 289 | 'verifyPeer' => $this->getVerifyPeer() 290 | ]; 291 | } 292 | 293 | /** 294 | * Basic JSON implementation. 295 | * 296 | * @return array 297 | */ 298 | public function jsonSerialize(): array { 299 | return [ 300 | "url" => $this->getUrl(), 301 | "proxiedToUrl" => $this->proxiedToUri !== null ? (string) $this->proxiedToUri : null, 302 | "host" => $this->proxiedToUri !== null ? $this->proxiedToUri->getHost() : $this->getUri()->getHost(), 303 | "method" => $this->getMethod(), 304 | ]; 305 | } 306 | 307 | /// 308 | /// PSR Implementation 309 | /// 310 | 311 | /** 312 | * @inheritDoc 313 | */ 314 | public function getRequestTarget(): string { 315 | $uri = $this->getUri(); 316 | $path = $uri->getPath(); 317 | $path = '/' . ltrim($path, '/'); 318 | 319 | $query = $uri->getQuery(); 320 | if ($query) { 321 | $path .= '?' . $query; 322 | } 323 | 324 | return $path; 325 | } 326 | 327 | /** 328 | * @inheritDoc 329 | */ 330 | public function withRequestTarget($requestTarget) { 331 | $uri = $this->getUri(); 332 | $pieces = parse_url($requestTarget); 333 | 334 | if (isset($pieces['path'])) { 335 | $uri = $uri->withPath($pieces['path']); 336 | } 337 | 338 | if (isset($pieces['query'])) { 339 | $uri = $uri->withQuery($pieces['query']); 340 | } 341 | return $this->withUri($uri); 342 | } 343 | 344 | /** 345 | * @inheritDoc 346 | */ 347 | public function withMethod($method) { 348 | $cloned = clone $this; 349 | $cloned->setMethod($method); 350 | return $cloned; 351 | } 352 | 353 | /** 354 | * @inheritDoc 355 | */ 356 | public function getUri(): UriInterface { 357 | return $this->uri; 358 | } 359 | 360 | /** 361 | * @inheritDoc 362 | */ 363 | public function withUri(UriInterface $uri, $preserveHost = false) { 364 | $cloned = clone $this; 365 | $cloned->setUrl((string) $uri); 366 | return $cloned; 367 | } 368 | 369 | /** 370 | * @return UriInterface|null 371 | */ 372 | public function getProxiedToUri(): ?UriInterface { 373 | return $this->proxiedToUri; 374 | } 375 | 376 | /** 377 | * @param UriInterface|null $proxiedToUri 378 | * @return void 379 | */ 380 | public function setProxiedToUri(?UriInterface $proxiedToUri): void { 381 | $this->proxiedToUri = $proxiedToUri; 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Garden HTTP 2 | 3 | [![CI Tests](https://github.com/vanilla/garden-http/actions/workflows/ci.yml/badge.svg)](https://github.com/vanilla/garden-http/actions/workflows/ci.yml) 4 | [![Packagist Version](https://img.shields.io/packagist/v/vanilla/garden-http.svg?style=flat)](https://packagist.org/packages/vanilla/garden-http) 5 | [![CLA](https://cla-assistant.io/readme/badge/vanilla/garden-http)](https://cla-assistant.io/vanilla/garden-http) 6 | 7 | Garden HTTP is an unbloated HTTP client library for building RESTful API clients. It's meant to allow you to access 8 | people's APIs without having to copy/paste a bunch of cURL setup and without having to double the size of your codebase. 9 | You can use this library as is for quick API clients or extend the `HttpClient` class to make structured API clients 10 | that you use regularly. 11 | 12 | ## Installation 13 | 14 | *Garden HTTP requires PHP 7.4 or higher and libcurl* 15 | 16 | Garden HTTP is [PSR-4](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader.md) compliant and can be installed using [composer](//getcomposer.org). Just add `vanilla/garden-http` to your composer.json. 17 | 18 | Garden request and response objects are [PSR-7](https://www.php-fig.org/psr/psr-7/) compliant as well. 19 | 20 | ## Basic Example 21 | 22 | Almost all uses of Garden HTTP involve first creating an `HttpClient` object and then making requests from it. 23 | You can see below a default header is also set to pass a standard header to every request made with the client. 24 | 25 | ```php 26 | use Garden\Http\HttpClient; 27 | 28 | $api = new HttpClient('http://httpbin.org'); 29 | $api->setDefaultHeader('Content-Type', 'application/json'); 30 | 31 | // Get some data from the API. 32 | $response = $api->get('/get'); // requests off of base url 33 | if ($response->isSuccessful()) { 34 | $data = $response->getBody(); // returns array of json decoded data 35 | } 36 | 37 | $response = $api->post('https://httpbin.org/post', ['foo' => 'bar']); 38 | if ($response->isResponseClass('2xx')) { 39 | // Access the response like an array. 40 | $posted = $response['json']; // should be ['foo' => 'bar'] 41 | } 42 | ``` 43 | 44 | ## Throwing Exceptions 45 | 46 | You can tell the HTTP client to throw an exception on unsuccessful requests. 47 | 48 | ```php 49 | use Garden\Http\HttpClient; 50 | 51 | $api = new HttpClient('https://httpbin.org'); 52 | $api->setThrowExceptions(true); 53 | 54 | try { 55 | $api->get('/status/404'); 56 | } catch (\Exception $ex) { 57 | $code = $ex->getCode(); // should be 404 58 | throw $ex; 59 | } 60 | 61 | // If you don't want a specific request to throw. 62 | $response = $api->get("/status/500", [], [], ["throw" => false]); 63 | // But you could throw it yourself. 64 | if (!$response->isSuccessful()) { 65 | throw $response->asException(); 66 | } 67 | ``` 68 | 69 | Exceptions will be thrown with a message indicating the failing response and structured data as well. 70 | 71 | ```php 72 | try { 73 | $response = new HttpResponse(501, ["content-type" => "application/json"], '{"message":"Some error occured."}'); 74 | throw $response->asException(); 75 | // Make an exception 76 | } catch (\Garden\Http\HttpResponseException $ex) { 77 | // Request POST /some/path failed with a response code of 501 and a custom message of "Some error occured." 78 | $ex->getMessage(); 79 | 80 | // [ 81 | // "request" => [ 82 | // 'url' => '/some/path', 83 | // 'method' => 'POST', 84 | // ], 85 | // "response" => [ 86 | // 'statusCode' => 501, 87 | // 'content-type' => 'application/json', 88 | // 'body' => '{"message":"Some error occured."}', 89 | // ] 90 | // ] 91 | $ex->getContext(); 92 | 93 | // It's serializable too. 94 | json_encode($ex); 95 | } 96 | ``` 97 | 98 | ## Basic Authentication 99 | 100 | You can specify a username and password for basic authentication using the `auth` option. 101 | 102 | ```PHP 103 | use Garden\Http\HttpClient; 104 | 105 | $api = new HttpClient('https://httpbin.org'); 106 | $api->setDefaultOption('auth', ['username', 'password123']); 107 | 108 | // This request is made with the default authentication set above. 109 | $r1 = $api->get('/basic-auth/username/password123'); 110 | 111 | // This request overrides the basic authentication. 112 | $r2 = $api->get('/basic-auth/username/password', [], [], ['auth' => ['username', 'password']]); 113 | ``` 114 | 115 | ## Extending the HttpClient through subclassing 116 | 117 | If you are going to be calling the same API over and over again you might want to extend the `HttpClient` class 118 | to make an API client that is more convenient to reuse. 119 | 120 | ```PHP 121 | use Garden\Http\HttpClient; 122 | use Garden\Http\HttpHandlerInterface 123 | 124 | // A custom HTTP client to access the github API. 125 | class GithubClient extends HttpClient { 126 | 127 | // Set default options in your constructor. 128 | public function __construct(HttpHandlerInterface $handler = null) { 129 | parent::__construct('https://api.github.com', $handler); 130 | $this 131 | ->setDefaultHeader('Content-Type', 'application/json') 132 | ->setThrowExceptions(true); 133 | } 134 | 135 | // Use a default header to authorize every request. 136 | public function setAccessToken($token) { 137 | $this->setDefaultHeader('Authorization', "Bearer $token"); 138 | } 139 | 140 | // Get the repos for a given user. 141 | public function getRepos($username = '') { 142 | if ($username) { 143 | return $this->get("/users/$username/repos"); 144 | } else { 145 | return $this->get("/user/repos"); // my repos 146 | } 147 | } 148 | 149 | // Create a new repo. 150 | public function createRepo($name, $description, $private) { 151 | return $this->post( 152 | '/user/repos', 153 | ['name' => $name, 'description' => $description, 'private' => $private] 154 | ); 155 | } 156 | 157 | // Get a repo. 158 | public function getRepo($owner, $repo) { 159 | return $this->get("/repos/$owner/$repo"); 160 | } 161 | 162 | // Edit a repo. 163 | public function editRepo($owner, $repo, $name, $description = null, $private = null) { 164 | return $this->patch( 165 | "/repos/$owner/$repo", 166 | ['name' => $name, 'description' => $description, 'private' => $private] 167 | ); 168 | } 169 | 170 | // Different APIs will return different responses on errors. 171 | // Override this method to handle errors in a way that is appropriate for the API. 172 | public function handleErrorResponse(HttpResponse $response, $options = []) { 173 | if ($this->val('throw', $options, $this->throwExceptions)) { 174 | $body = $response->getBody(); 175 | if (is_array($body)) { 176 | $message = $this->val('message', $body, $response->getReasonPhrase()); 177 | } else { 178 | $message = $response->getReasonPhrase(); 179 | } 180 | throw new \HttpResponseExceptionException($response, $message); 181 | } 182 | } 183 | } 184 | ``` 185 | 186 | ## Extending the HttpClient with middleware 187 | 188 | The `HttpClient` class has an `addMiddleware()` method that lets you add a function that can modify the request and response before and after being sent. Middleware lets you develop a library of reusable utilities that can be used with any client. Middleware is good for things like advanced authentication, caching layers, CORS support, etc. 189 | 190 | ### Writing middleware 191 | 192 | Middleware is a callable that accepts two arguments: an `HttpRequest` object, and the next middleware. Each middleware must return an `HttpResponse` object. 193 | 194 | ```php 195 | function (HttpRequest $request, callable $next): HttpResponse { 196 | // Do something to the request. 197 | $request->setHeader('X-Foo', '...'); 198 | 199 | // Call the next middleware to get the response. 200 | $response = $next($request); 201 | 202 | // Do something to the response. 203 | $response->setHeader('Cache-Control', 'public, max-age=31536000'); 204 | 205 | return $response; 206 | } 207 | ``` 208 | 209 | You have to call `$next` or else the request won't be processed by the `HttpClient`. Of course, you may want to short circuit processing of the request in the case of a caching layer so in that case you can leave out the call to `$next`. 210 | 211 | ### Example: Modifying the request with middleware 212 | 213 | Consider the following class that implements HMAC SHA256 hashing for a hypothetical API that expects more than just a static access token. 214 | 215 | ```php 216 | class HmacMiddleware { 217 | protected $apiKey; 218 | 219 | protected $secret; 220 | 221 | public function __construct(string $apiKey, string $secret) { 222 | $this->apiKey = $apiKey; 223 | $this->secret = $secret; 224 | } 225 | 226 | public function __invoke(HttpRequest $request, callable $next): HttpResponse { 227 | $msg = time().$this->apiKey; 228 | $sig = hash_hmac('sha256', $msg, $this->secret); 229 | 230 | $request->setHeader('Authorization', "$msg.$sig"); 231 | 232 | return $next($request); 233 | } 234 | } 235 | ``` 236 | 237 | This middleware calculates a new authorization header for each request and then adds it to the request. It then calls the `$next` closure to perform the rest of the request. 238 | 239 | ## The HttpHandlerInterface 240 | 241 | In Garden HTTP, requests are executed with an HTTP handler. The currently included default handler executes requests with cURL. However, you can implement the the `HttpHandlerInterface` however you want and completely change the way requests are handled. The interface includes only one method: 242 | 243 | ```php 244 | public function send(HttpRequest $request): HttpResponse; 245 | ``` 246 | 247 | The method is supposed to transform a request into a response. To use it, just pass an `HttpRequest` object to it. 248 | 249 | You can also use your custom handler with the `HttpClient`. Just pass it to the constructor: 250 | 251 | ```json 252 | $api = new HttpClient('https://example.com', new CustomHandler()); 253 | ``` 254 | 255 | ## Inspecting requests and responses 256 | 257 | Sometimes when you get a response you want to know what request generated it. The `HttpResponse` class has an `getRequest()` method for this. The `HttpRequest` class has a `getResponse()` method for the inverse. 258 | 259 | Exceptions that are thrown from `HttpClient` objects are instances of the `HttpResponseException` class. That class has `getRequest()` and `getResponse()` methods so that you can inspect both the request and the response for the exception. This exception is of particular use since request objects are created inside the client and not by the programmer directly. 260 | 261 | ## Mocking for Tests 262 | 263 | An `HttpHandlerInterface` implementation and utilities are provided for mocking requests and responses. 264 | 265 | ### Setup 266 | 267 | ```php 268 | use Garden\Http\HttpClient 269 | use Garden\Http\Mocks\MockHttpHandler; 270 | 271 | // Manually apply the handler. 272 | $httpClient = new HttpClient(); 273 | $mockHandler = new MockHttpHandler(); 274 | $httpClient->setHandler($mockHandler); 275 | 276 | // Automatically apply a handler to `HttpClient` instances. 277 | // You can call this again later to retrieve the same handler. 278 | $mockHandler = MockHttpHandler::mock(); 279 | 280 | // Don't forget this in your phpunit `teardown()` 281 | MockHttpHandler::clearMock();; 282 | 283 | // Reset the handler instance 284 | $mockHandler->reset(); 285 | ``` 286 | 287 | ### Mocking Requests 288 | 289 | ```php 290 | use Garden\Http\Mocks\MockHttpHandler; 291 | use Garden\Http\Mocks\MockResponse; 292 | 293 | // By default this will return 404 for all requests. 294 | $mockHttp = MockHttpHandler::mock(); 295 | 296 | $mockHttp 297 | // Explicit request and response 298 | ->addMockRequest( 299 | new \Garden\Http\HttpRequest("GET", "https://domain.com/some/url"), 300 | new \Garden\Http\HttpResponse(200, ["content-type" => "application/json"], '{"json": "here"}'), 301 | ) 302 | // Shorthand 303 | ->addMockRequest( 304 | "GET https://domain.com/some/url", 305 | MockResponse::json(["json" => "here"]) 306 | ) 307 | // Even shorter-hand 308 | // Mocking 200 JSON responses to GET requests is very easy. 309 | ->addMockRequest( 310 | "https://domain.com/some/url", 311 | ["json" => "here"] 312 | ) 313 | 314 | // Wildcards 315 | // Wildcards match with lower priority than explicitly matching requests. 316 | 317 | // Explicit wildcard hostname. 318 | ->addMockRequest("https://*/some/path", MockResponse::success()) 319 | // Implied wildcard hostname. 320 | ->addMockRequest("/some/path", MockResponse::success()) 321 | // wildcard in path 322 | ->addMockRequest("https://some-doain.com/some/*", MockResponse::success()) 323 | // Total wildcard 324 | ->addMockRequest("*", MockResponse::notFound()) 325 | ; 326 | 327 | // Mock multiple requests at once 328 | $mockHttp->mockMulti([ 329 | "GET /some/path" => MockResponse::success() 330 | "POST /other/path" => MockResponse::json([]) 331 | ]); 332 | ``` 333 | 334 | ### Response Sequences 335 | 336 | Anywhere you can use a mocked `HttpResponse` you can also use a `MockHttpSequence`. 337 | 338 | Each item pushed into the sequence will return exactly once. Once that response has been returned it will not be returned again. 339 | 340 | If the whole sequence is exhausted it will return 404 responses. 341 | 342 | ```php 343 | use Garden\Http\Mocks\MockHttpHandler; 344 | use Garden\Http\Mocks\MockResponse; 345 | 346 | $mockHttp = MockHttpHandler::mock(); 347 | 348 | $mockHttp->mockMulti([ 349 | "GET /some/path" => MockResponse::sequence() 350 | ->push(new \Garden\Http\HttpResponse(500, [], "")) 351 | ->push(MockResponse::success()) 352 | ->push(MockResponse::json([]) 353 | ->push([]) // Implied json 354 | , 355 | ]); 356 | ``` 357 | 358 | ### Response Functions 359 | 360 | You can make a mock dynamic by providing a callable. 361 | 362 | ```php 363 | use Garden\Http\Mocks\MockHttpHandler; 364 | use Garden\Http\Mocks\MockResponse; 365 | use \Garden\Http\HttpRequest; 366 | use \Garden\Http\HttpResponse; 367 | 368 | $mockHttp = MockHttpHandler::mock(); 369 | $mockHttp->addMockRequest("*", function (\Garden\Http\HttpRequest $request): HttpResponse { 370 | return MockResponse::json([ 371 | "requestedUrl" => $request->getUrl(), 372 | ]); 373 | }) 374 | ``` 375 | 376 | ### Assertions about requests 377 | 378 | Some utilities are provided to make assertions against requests that were made. This can be particularly useful with a wildcard response. 379 | 380 | ```php 381 | use Garden\Http\Mocks\MockHttpHandler; 382 | use Garden\Http\Mocks\MockResponse; 383 | use Garden\Http\HttpRequest; 384 | 385 | $mockHttp = MockHttpHandler::mock(); 386 | 387 | $mockHttp->addMockRequest("*", MockResponse::success()); 388 | 389 | // Ensure no requests were made. 390 | $mockHttp->assertNothingSent(); 391 | 392 | // Check that a request was made 393 | $foundRequest = $mockHttp->assertSent(fn (HttpRequest $request) => $request->getUri()->getPath() === "/some/path"); 394 | 395 | // Check that a request was not made. 396 | $foundRequest = $mockHttp->assertNotSent(fn (HttpRequest $request) => $request->getUri()->getPath() === "/some/path"); 397 | 398 | // Clear the history (and mocked requests) 399 | $mockHttp->reset(); 400 | ``` 401 | -------------------------------------------------------------------------------- /src/HttpClient.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2019 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http; 9 | 10 | 11 | /** 12 | * Represents an client connection to a RESTful API. 13 | */ 14 | #[\AllowDynamicProperties] 15 | class HttpClient { 16 | 17 | /// Properties /// 18 | 19 | /** 20 | * @var string The base URL prefix of the API. 21 | */ 22 | protected $baseUrl; 23 | 24 | /** 25 | * @var array The default headers to send with each API request. 26 | */ 27 | protected $defaultHeaders = []; 28 | 29 | /** 30 | * @var array The default options for all requests. 31 | */ 32 | protected $defaultOptions = []; 33 | 34 | /** 35 | * @var bool Whether or not to throw exceptions on non-2xx responses. 36 | */ 37 | protected $throwExceptions = false; 38 | 39 | /** 40 | * @var callable Middleware that modifies requests and responses. 41 | */ 42 | protected $middleware; 43 | 44 | /** 45 | * @var HttpHandlerInterface The handler that will send the actual requests. 46 | */ 47 | protected $handler; 48 | 49 | /// Methods /// 50 | 51 | /** 52 | * Initialize a new instance of the {@link HttpClient} class. 53 | * 54 | * @param string $baseUrl The base URL prefix of the API. 55 | * @param HttpHandlerInterface The handler that will send the actual requests. 56 | */ 57 | public function __construct(string $baseUrl = '', HttpHandlerInterface $handler = null) { 58 | $this->baseUrl = $baseUrl; 59 | $this->setHandler($handler === null ? new CurlHandler() : $handler); 60 | $this->setDefaultHeader('User-Agent', 'garden-http/2 (HttpRequest)'); 61 | $this->middleware = function (HttpRequest $request): HttpResponse { 62 | return $request->send($this->getHandler()); 63 | }; 64 | } 65 | 66 | /** 67 | * Construct and append a querystring array to a uri. 68 | * 69 | * @param string $uri The uri to append the query to. 70 | * @param array $query The query to turn into a querystring. 71 | * @return string Returns the final uri. 72 | */ 73 | protected static function appendQuery(string $uri, array $query = []): string { 74 | if (!empty($query)) { 75 | $qs = http_build_query($query); 76 | $uri .= (strpos($uri, '?') === false ? '?' : '&').$qs; 77 | } 78 | return $uri; 79 | } 80 | 81 | /** 82 | * Create a new {@link HttpRequest} object with properties filled out from the API client's settings. 83 | * 84 | * @param string $method The HTTP method of the request. 85 | * @param string $uri The URL or path of the request. 86 | * @param string|array $body The body of the request. 87 | * @param array $headers An array of HTTP headers to add to the request. 88 | * @param array $options Additional options to be sent with the request. 89 | * @return HttpRequest Returns the new {@link HttpRequest} object. 90 | */ 91 | public function createRequest(string $method, string $uri, $body, array $headers = [], array $options = []) { 92 | if (strpos($uri, '//') === false) { 93 | $uri = $this->baseUrl.'/'.ltrim($uri, '/'); 94 | } 95 | 96 | $headers = array_replace($this->defaultHeaders, $headers); 97 | $options = array_replace($this->defaultOptions, $options); 98 | 99 | $request = new HttpRequest($method, $uri, $body, $headers, $options); 100 | return $request; 101 | } 102 | 103 | /** 104 | * Send a DELETE request to the API. 105 | * 106 | * @param string $uri The URL or path of the request. 107 | * @param array $query The querystring to add to the URL. 108 | * @param array $headers The HTTP headers to add to the request. 109 | * @param array $options An array of additional options for the request. 110 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 111 | */ 112 | public function delete(string $uri, array $query = [], array $headers = [], array $options = []) { 113 | $uri = static::appendQuery($uri, $query); 114 | 115 | return $this->request(HttpRequest::METHOD_DELETE, $uri, '', $headers, $options); 116 | } 117 | 118 | /** 119 | * Send a GET request to the API. 120 | * 121 | * @param string $uri The URL or path of the request. 122 | * @param array $query The querystring to add to the URL. 123 | * @param array $headers The HTTP headers to add to the request. 124 | * @param array $options An array of additional options for the request. 125 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 126 | */ 127 | public function get(string $uri, array $query = [], array $headers = [], $options = []) { 128 | $uri = static::appendQuery($uri, $query); 129 | 130 | return $this->request(HttpRequest::METHOD_GET, $uri, '', $headers, $options); 131 | } 132 | 133 | /** 134 | * Handle a non 200 series response from the API. 135 | * 136 | * This method is here specifically for sub-classes to override. When an API call is made and a non-200 series 137 | * status code is returned then this method is called with that response. This lets API client authors to extract 138 | * the appropriate error message out of the response and decide whether or not to throw a PHP exception. 139 | * 140 | * It is recommended that you obey the caller's wishes on whether or not to throw an exception by using the 141 | * following `if` statement: 142 | * 143 | * ```php 144 | * if ($options['throw'] ?? $this->throwExceptions) { 145 | * ... 146 | * } 147 | * ``` 148 | * 149 | * @param HttpResponse $response The response sent from the API. 150 | * @param array $options The options that were sent with the request. 151 | * @throws HttpResponseException Throws an exception if the settings or options say to throw an exception. 152 | */ 153 | public function handleErrorResponse(HttpResponse $response, $options = []) { 154 | if ($options['throw'] ?? $this->throwExceptions) { 155 | throw $response->asException(); 156 | } 157 | } 158 | 159 | /** 160 | * Send a HEAD request to the API. 161 | * 162 | * @param string $uri The URL or path of the request. 163 | * @param array $query The querystring to add to the URL. 164 | * @param array $headers The HTTP headers to add to the request. 165 | * @param array $options An array of additional options for the request. 166 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 167 | */ 168 | public function head(string $uri, array $query = [], array $headers = [], $options = []) { 169 | $uri = static::appendQuery($uri, $query); 170 | return $this->request(HttpRequest::METHOD_HEAD, $uri, '', $headers, $options); 171 | } 172 | 173 | 174 | /** 175 | * Send an OPTIONS request to the API. 176 | * 177 | * @param string $uri The URL or path of the request. 178 | * @param array $query The querystring to add to the URL. 179 | * @param array $headers The HTTP headers to add to the request. 180 | * @param array $options An array of additional options for the request. 181 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 182 | */ 183 | public function options(string $uri, array $query = [], array $headers = [], $options = []) { 184 | $uri = static::appendQuery($uri, $query); 185 | return $this->request(HttpRequest::METHOD_OPTIONS, $uri, '', $headers, $options); 186 | } 187 | 188 | /** 189 | * Send a PATCH request to the API. 190 | * 191 | * @param string $uri The URL or path of the request. 192 | * @param array|string $body The HTTP body to send to the request or an array to be appropriately encoded. 193 | * @param array $headers The HTTP headers to add to the request. 194 | * @param array $options An array of additional options for the request. 195 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 196 | */ 197 | public function patch(string $uri, $body = [], array $headers = [], $options = []) { 198 | return $this->request(HttpRequest::METHOD_PATCH, $uri, $body, $headers, $options); 199 | } 200 | 201 | /** 202 | * Send a POST request to the API. 203 | * 204 | * @param string $uri The URL or path of the request. 205 | * @param array|string $body The HTTP body to send to the request or an array to be appropriately encoded. 206 | * @param array $headers The HTTP headers to add to the request. 207 | * @param array $options An array of additional options for the request. 208 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 209 | */ 210 | public function post(string $uri, $body = [], array $headers = [], $options = []) { 211 | return $this->request(HttpRequest::METHOD_POST, $uri, $body, $headers, $options); 212 | } 213 | 214 | /** 215 | * Send a PUT request to the API. 216 | * 217 | * @param string $uri The URL or path of the request. 218 | * @param array|string $body The HTTP body to send to the request or an array to be appropriately encoded. 219 | * @param array $headers The HTTP headers to add to the request. 220 | * @param array $options An array of additional options for the request. 221 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 222 | */ 223 | public function put(string $uri, $body = [], array $headers = [], $options = []) { 224 | return $this->request(HttpRequest::METHOD_PUT, $uri, $body, $headers, $options); 225 | } 226 | 227 | /** 228 | * Make a generic HTTP request against the API. 229 | * 230 | * @param string $method The HTTP method of the request. 231 | * @param string $uri The URL or path of the request. 232 | * @param array|string $body The HTTP body to send to the request or an array to be appropriately encoded. 233 | * @param array $headers The HTTP headers to add to the request. 234 | * @param array $options An array of additional options for the request. 235 | * @return HttpResponse Returns the {@link HttpResponse} object from the call. 236 | */ 237 | public function request(string $method, string $uri, $body, array $headers = [], array $options = []) { 238 | $request = $this->createRequest($method, $uri, $body, $headers, $options); 239 | // Call the chain of middleware on the request. 240 | $response = call_user_func($this->middleware, $request); 241 | 242 | if (!$response->isResponseClass('2xx')) { 243 | $this->handleErrorResponse($response, $options); 244 | } 245 | 246 | return $response; 247 | } 248 | 249 | /** 250 | * Get the base URL of the API. 251 | * 252 | * @return string Returns the baseUrl. 253 | */ 254 | public function getBaseUrl(): string { 255 | return $this->baseUrl; 256 | } 257 | 258 | /** 259 | * Set the base URL of the API. 260 | * 261 | * @param string $baseUrl The base URL of the API. 262 | * @return HttpClient Returns `$this` for fluent calls. 263 | */ 264 | public function setBaseUrl(string $baseUrl) { 265 | $this->baseUrl = rtrim($baseUrl, '/'); 266 | return $this; 267 | } 268 | 269 | /** 270 | * Get the value of a default header. 271 | * 272 | * Default headers are sent along with all requests. 273 | * 274 | * @param string $name The name of the header to get. 275 | * @param mixed $default The value to return if there is no default header. 276 | * @return mixed Returns the value of the default header. 277 | */ 278 | public function getDefaultHeader(string $name, $default = null) { 279 | return $this->defaultHeaders[$name] ?? $default; 280 | } 281 | 282 | /** 283 | * Set the value of a default header. 284 | * 285 | * @param string $name The name of the header to set. 286 | * @param string $value The new value of the default header. 287 | * @return HttpClient Returns `$this` for fluent calls. 288 | */ 289 | public function setDefaultHeader(string $name, string $value) { 290 | $this->defaultHeaders[$name] = $value; 291 | return $this; 292 | } 293 | 294 | /** 295 | * Get the all the default headers. 296 | * 297 | * The default headers are added to every request. 298 | * 299 | * @return array Returns the default headers. 300 | */ 301 | public function getDefaultHeaders(): array { 302 | return $this->defaultHeaders; 303 | } 304 | 305 | /** 306 | * Set the default headers. 307 | * 308 | * @param array $defaultHeaders The new default headers. 309 | * @return HttpClient Returns `$this` for fluent calls. 310 | */ 311 | public function setDefaultHeaders(array $defaultHeaders) { 312 | $this->defaultHeaders = $defaultHeaders; 313 | return $this; 314 | } 315 | 316 | /** 317 | * Get the value of a default option. 318 | * 319 | * @param string $name The name of the default option. 320 | * @param mixed $default The value to return if there is no default option set. 321 | * @return mixed Returns the default option or {@link $default}. 322 | */ 323 | public function getDefaultOption(string $name, $default = null) { 324 | return $this->defaultOptions[$name] ?? $default; 325 | } 326 | 327 | /** 328 | * Set the value of a default option. 329 | * 330 | * @param string $name The name of the default option. One of the {@link HttpRequest::OPT_*} constants. 331 | * @param mixed $value The new value of the default option. 332 | * @return HttpClient Returns `$this` for fluent calls. 333 | */ 334 | public function setDefaultOption(string $name, $value) { 335 | $this->defaultOptions[$name] = $value; 336 | return $this; 337 | } 338 | 339 | /** 340 | * Get all of the default options. 341 | * 342 | * @return array Returns an array of default options. 343 | */ 344 | public function getDefaultOptions(): array { 345 | return $this->defaultOptions; 346 | } 347 | 348 | /** 349 | * Gets whether or not to throw exceptions when an error response is returned from a request. 350 | * 351 | * @return boolean Returns `true` if exceptions should be thrown or `false` otherwise. 352 | */ 353 | public function getThrowExceptions(): bool { 354 | return $this->throwExceptions; 355 | } 356 | 357 | /** 358 | * Sets whether or not to throw exceptions when an error response is returned from a request. 359 | * 360 | * @param boolean $throwExceptions Whether or not to throw exceptions when an error response is encountered. 361 | * @return HttpClient Returns `$this` for fluent calls. 362 | */ 363 | public function setThrowExceptions(bool $throwExceptions) { 364 | $this->throwExceptions = $throwExceptions; 365 | return $this; 366 | } 367 | 368 | /** 369 | * Set the default options. 370 | * 371 | * @param array $defaultOptions The new default options array. 372 | * Keys are the OPT_* constants from {@link HttpRequest::OPT_*}. 373 | * @return HttpClient Returns `$this` for fluent calls. 374 | */ 375 | public function setDefaultOptions(array $defaultOptions) { 376 | $this->defaultOptions = $defaultOptions; 377 | return $this; 378 | } 379 | 380 | /** 381 | * Add a middleware function to the client. 382 | * 383 | * A Middleware is a callable that has the following signature: 384 | * 385 | * ```php 386 | * function middleware(HttpRequest $request, callable $next): HttpResponse { 387 | * // Optionally modify the request. 388 | * $request->setHeader('X-Foo', 'bar'); 389 | * 390 | * // Process the request by calling $next. You must always call next. 391 | * $response = $next($request); 392 | * 393 | * // Optionally modify the response. 394 | * $response->setHeader('Access-Control-Allow-Origin', '*'); 395 | * 396 | * return $response; 397 | * } 398 | * ``` 399 | * 400 | * @param callable $middleware The middleware callback to add. 401 | * @return $this 402 | */ 403 | public function addMiddleware(callable $middleware) { 404 | $next = $this->middleware; 405 | 406 | $this->middleware = function (HttpRequest $request) use ($middleware, $next): HttpResponse { 407 | return $middleware($request, $next); 408 | }; 409 | 410 | return $this; 411 | } 412 | 413 | /** 414 | * Get the HTTP handler that will send the actual requests. 415 | * 416 | * @return HttpHandlerInterface Returns the current handler. 417 | */ 418 | public function getHandler(): HttpHandlerInterface { 419 | return $this->handler; 420 | } 421 | 422 | /** 423 | * Set the HTTP handler that will send the actual requests. 424 | * 425 | * @param HttpHandlerInterface $handler The new handler. 426 | * @return $this 427 | */ 428 | public function setHandler(HttpHandlerInterface $handler) { 429 | $this->handler = $handler; 430 | return $this; 431 | } 432 | 433 | /** 434 | * Safely get a value out of an array. 435 | * 436 | * @param string|int $key The array key. 437 | * @param array $array The array to get the value from. 438 | * @param mixed $default The default value to return if the key doesn't exist. 439 | * @return mixed The item from the array or `$default` if the array key doesn't exist. 440 | * @deprecated 441 | */ 442 | protected function val($key, $array, $default = null) { 443 | if (isset($array[$key])) { 444 | return $array[$key]; 445 | } 446 | return $default; 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /src/HttpResponse.php: -------------------------------------------------------------------------------- 1 | 4 | * @copyright 2009-2019 Vanilla Forums Inc. 5 | * @license MIT 6 | */ 7 | 8 | namespace Garden\Http; 9 | 10 | use Psr\Http\Message\ResponseInterface; 11 | 12 | /** 13 | * Representation of an outgoing, server-side response. 14 | */ 15 | class HttpResponse extends HttpMessage implements 16 | \ArrayAccess, 17 | \JsonSerializable, 18 | ResponseInterface 19 | { 20 | /// Properties /// 21 | 22 | /** 23 | * @var int|null 24 | */ 25 | protected $statusCode; 26 | 27 | /** 28 | * @var string 29 | */ 30 | protected $reasonPhrase; 31 | 32 | /** 33 | * @var string 34 | */ 35 | protected $rawBody; 36 | 37 | /** 38 | * @var HttpRequest 39 | */ 40 | protected $request; 41 | 42 | /** 43 | * @var array HTTP response codes and messages. 44 | */ 45 | protected static $reasonPhrases = [ 46 | // Could not resolve host. 47 | 0 => "Could not resolve host", 48 | // Informational 1xx 49 | 100 => "Continue", 50 | 101 => "Switching Protocols", 51 | // Successful 2xx 52 | 200 => "OK", 53 | 201 => "Created", 54 | 202 => "Accepted", 55 | 203 => "Non-Authoritative Information", 56 | 204 => "No Content", 57 | 205 => "Reset Content", 58 | 206 => "Partial Content", 59 | // Redirection 3xx 60 | 300 => "Multiple Choices", 61 | 301 => "Moved Permanently", 62 | 302 => "Found", 63 | 303 => "See Other", 64 | 304 => "Not Modified", 65 | 305 => "Use Proxy", 66 | 306 => "(Unused)", 67 | 307 => "Temporary Redirect", 68 | // Client Error 4xx 69 | 400 => "Bad Request", 70 | 401 => "Unauthorized", 71 | 402 => "Payment Required", 72 | 403 => "Forbidden", 73 | 404 => "Not Found", 74 | 405 => "Method Not Allowed", 75 | 406 => "Not Acceptable", 76 | 407 => "Proxy Authentication Required", 77 | 408 => "Request Timeout", 78 | 409 => "Conflict", 79 | 410 => "Gone", 80 | 411 => "Length Required", 81 | 412 => "Precondition Failed", 82 | 413 => "Request Entity Too Large", 83 | 414 => "Request-URI Too Long", 84 | 415 => "Unsupported Media Type", 85 | 416 => "Requested Range Not Satisfiable", 86 | 417 => "Expectation Failed", 87 | 418 => 'I\'m a teapot', 88 | 422 => "Unprocessable Entity", 89 | 423 => "Locked", 90 | // Server Error 5xx 91 | 500 => "Internal Server Error", 92 | 501 => "Not Implemented", 93 | 502 => "Bad Gateway", 94 | 503 => "Service Unavailable", 95 | 504 => "Gateway Timeout", 96 | 505 => "HTTP Version Not Supported", 97 | ]; 98 | 99 | /// Methods /// 100 | 101 | /** 102 | * Initialize an instance of the {@link HttpResponse} object. 103 | * 104 | * @param int|null $status The http response status or null to get the status from the headers. 105 | * @param array|string $headers An array of response headers or a header string. 106 | * @param string $rawBody The raw body of the response. 107 | */ 108 | public function __construct( 109 | $status = null, 110 | $headers = "", 111 | string $rawBody = "" 112 | ) { 113 | $this->setHeaders($headers); 114 | if (isset($status)) { 115 | $this->setStatus($status); 116 | } elseif ($this->statusCode === null) { 117 | $this->setStatus(200); 118 | } 119 | $this->rawBody = $rawBody; 120 | } 121 | 122 | /** 123 | * Gets the body of the response, decoded according to its content type. 124 | * 125 | * @return mixed Returns the http response body, decoded according to its content type. 126 | */ 127 | public function getBody() 128 | { 129 | if (!isset($this->body)) { 130 | $contentType = $this->getHeader("Content-Type"); 131 | 132 | if ( 133 | !is_null($this->rawBody) && 134 | stripos($contentType, "application/json") !== false 135 | ) { 136 | $this->body = json_decode($this->rawBody, true); 137 | } else { 138 | $this->body = $this->rawBody; 139 | } 140 | } 141 | return $this->body; 142 | } 143 | 144 | /** 145 | * Set the body of the response. 146 | * 147 | * This method will try and keep the raw body in sync with the value set here. 148 | * 149 | * @param mixed $body The new body. 150 | * @return $this 151 | */ 152 | public function setBody($body) 153 | { 154 | if (is_string($body) || is_null($body)) { 155 | $this->rawBody = $this->body = $body; 156 | } elseif ( 157 | is_array($body) || 158 | is_bool($body) || 159 | is_numeric($body) || 160 | $body instanceof \JsonSerializable 161 | ) { 162 | $this->rawBody = json_encode( 163 | $body, 164 | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES 165 | ); 166 | $this->body = $body; 167 | } else { 168 | $this->rawBody = ""; 169 | $this->body = $body; 170 | } 171 | 172 | return $this; 173 | } 174 | 175 | /** 176 | * Set all of the headers. This will overwrite any existing headers. 177 | * 178 | * @param array|string $headers An array or string of headers to set. 179 | * 180 | * The array of headers can be in the following form: 181 | * 182 | * - ["Header-Name" => "value", ...] 183 | * - ["Header-Name" => ["lines, ...], ...] 184 | * - ["Header-Name: value", ...] 185 | * - Any combination of the above formats. 186 | * 187 | * A header string is the the form of the HTTP standard where each Key: Value pair is separated by `\r\n`. 188 | * 189 | * @return HttpResponse Returns `$this` for fluent calls. 190 | */ 191 | public function setHeaders($headers) 192 | { 193 | parent::setHeaders($headers); 194 | 195 | if ($statusLine = $this->parseStatusLine($headers)) { 196 | $this->setStatus($statusLine); 197 | } 198 | 199 | return $this; 200 | } 201 | 202 | /** 203 | * Check if the provided response matches the provided response type. 204 | * 205 | * The {@link $class} is a string representation of the HTTP status code, with 'x' used as a wildcard. 206 | * 207 | * Class '2xx' = All 200-level responses 208 | * Class '30x' = All 300-level responses up to 309 209 | * 210 | * @param string $class A string representation of the HTTP status code, with 'x' used as a wildcard. 211 | * @return boolean Returns `true` if the response code matches the {@link $class}, `false` otherwise. 212 | */ 213 | public function isResponseClass(string $class): bool 214 | { 215 | $pattern = 216 | "`^" . str_ireplace("x", "\d", preg_quote($class, "`")) . '$`'; 217 | $result = preg_match($pattern, $this->statusCode); 218 | 219 | return $result === 1; 220 | } 221 | 222 | /** 223 | * Determine if the response was successful. 224 | * 225 | * @return bool Returns `true` if the response was a successful 2xx code. 226 | */ 227 | public function isSuccessful(): bool 228 | { 229 | return $this->isResponseClass("2xx"); 230 | } 231 | 232 | /** 233 | * Get the raw body of the response. 234 | * 235 | * @return string The raw body of the response. 236 | */ 237 | public function getRawBody(): string 238 | { 239 | return $this->rawBody; 240 | } 241 | 242 | /** 243 | * Set the raw body of the response. 244 | * 245 | * @param string $body The new raw body. 246 | */ 247 | public function setRawBody(string $body) 248 | { 249 | $this->rawBody = $body; 250 | $this->body = null; 251 | return $this; 252 | } 253 | 254 | /** 255 | * Convert this object to a string. 256 | * 257 | * @return string Returns the raw body of the response. 258 | */ 259 | public function __toString(): string 260 | { 261 | return $this->rawBody; 262 | } 263 | 264 | /** 265 | * Get the HTTP response status line. 266 | * 267 | * @return string Returns the status code and reason phrase separated by a space. 268 | */ 269 | public function getStatus(): string 270 | { 271 | return trim("{$this->statusCode} {$this->reasonPhrase}"); 272 | } 273 | 274 | /** 275 | * Set the status of the response. 276 | * 277 | * @param int|string $code Either the 3-digit integer result code or an entire HTTP status line. 278 | * @param string|null $reasonPhrase The reason phrase to go with the status code. 279 | * If no reason is given then one will be determined from the status code. 280 | * @return $this 281 | */ 282 | public function setStatus($code, $reasonPhrase = null) 283 | { 284 | if ( 285 | preg_match( 286 | "`(?:HTTP/([\d.]+)\s+)?(\d{3})\s*(.*)`i", 287 | $code, 288 | $matches 289 | ) 290 | ) { 291 | $this->protocolVersion = $matches[1] ?: $this->protocolVersion; 292 | $code = (int) $matches[2]; 293 | $reasonPhrase = $reasonPhrase ?: $matches[3]; 294 | } 295 | 296 | if (empty($reasonPhrase) && isset(static::$reasonPhrases[$code])) { 297 | $reasonPhrase = static::$reasonPhrases[$code]; 298 | } 299 | if (is_numeric($code)) { 300 | $this->setStatusCode((int) $code); 301 | } 302 | $this->setReasonPhrase((string) $reasonPhrase); 303 | return $this; 304 | } 305 | 306 | /** 307 | * Get the code. 308 | * 309 | * @return int Returns the code. 310 | */ 311 | public function getStatusCode(): int 312 | { 313 | return $this->statusCode ?? 200; 314 | } 315 | 316 | /** 317 | * Set the HTTP status code of the response. 318 | * 319 | * @param int $statusCode The new status code of the response. 320 | * @return HttpResponse Returns `$this` for fluent calls. 321 | */ 322 | public function setStatusCode(int $statusCode) 323 | { 324 | $this->statusCode = $statusCode; 325 | return $this; 326 | } 327 | 328 | /** 329 | * Get the HTTP reason phrase of the response. 330 | * 331 | * @return string Returns the reason phrase. 332 | */ 333 | public function getReasonPhrase(): string 334 | { 335 | if ($this->statusCode === 0 && !empty($this->rawBody)) { 336 | // CURL often returns a 0 error code if it failed to connect. 337 | // This could be for multiple reasons. We need the actual message provided to differentiate between 338 | // a timeout vs a DNS resolution failure. 339 | return $this->rawBody; 340 | } else { 341 | return $this->reasonPhrase; 342 | } 343 | } 344 | 345 | /** 346 | * Set the reason phrase of the status. 347 | * 348 | * @param string $reasonPhrase The new reason phrase. 349 | * @return HttpResponse Returns `$this` for fluent calls. 350 | */ 351 | public function setReasonPhrase(string $reasonPhrase) 352 | { 353 | $this->reasonPhrase = $reasonPhrase; 354 | return $this; 355 | } 356 | 357 | /** 358 | * Get the reason phrase for a status. 359 | * 360 | * @param int $status The status to test. 361 | * @return string|null Returns a reason phrase or null for an invalid status. 362 | */ 363 | public static function reasonPhrase(int $status): ?string 364 | { 365 | return self::$reasonPhrases[$status] ?? null; 366 | } 367 | 368 | /** 369 | * Parse the status line from a header string or array. 370 | * 371 | * @param string|array $headers Either a header string or a header array. 372 | * @return string Returns the status line or an empty string if the first line is not an HTTP status. 373 | */ 374 | private function parseStatusLine($headers): string 375 | { 376 | if (empty($headers)) { 377 | return ""; 378 | } 379 | 380 | if (is_string($headers)) { 381 | if ( 382 | preg_match_all( 383 | '`(?:^|\n)(HTTP/[^\r]+)\r\n`', 384 | $headers, 385 | $matches 386 | ) 387 | ) { 388 | $firstLine = end($matches[1]); 389 | } else { 390 | $firstLine = trim(strstr($headers, "\r\n", true)); 391 | } 392 | } else { 393 | $firstLine = (string) reset($headers); 394 | } 395 | 396 | // Test the status line. 397 | if (strpos($firstLine, "HTTP/") === 0) { 398 | return $firstLine; 399 | } 400 | return ""; 401 | } 402 | 403 | /** 404 | * Whether an offset exists. 405 | * 406 | * The is one of the methods of {@link \ArrayAccess} used to access this object as an array. 407 | * When using this object as an array the response body is referenced. 408 | * 409 | * @param mixed $offset An offset to check for. 410 | * @return boolean true on success or false on failure. 411 | * 412 | * The return value will be casted to boolean if non-boolean was returned. 413 | * @link http://php.net/manual/en/arrayaccess.offsetexists.php 414 | */ 415 | public function offsetExists($offset): bool 416 | { 417 | $body = $this->getBody(); 418 | return isset($body[$offset]); 419 | } 420 | 421 | /** 422 | * Retrieve a value at a given array offset. 423 | * 424 | * The is one of the methods of {@link \ArrayAccess} used to access this object as an array. 425 | * When using this object as an array the response body is referenced. 426 | * 427 | * @param mixed $offset The offset to retrieve. 428 | * @return mixed Can return all value types. 429 | * @link http://php.net/manual/en/arrayaccess.offsetget.php 430 | */ 431 | #[\ReturnTypeWillChange] 432 | public function offsetGet($offset) { 433 | $this->getBody(); 434 | $result = isset($this->body[$offset]) ? $this->body[$offset] : null; 435 | return $result; 436 | } 437 | 438 | /** 439 | * Set a value at a given array offset. 440 | * 441 | * The is one of the methods of {@link \ArrayAccess} used to access this object as an array. 442 | * When using this object as an array the response body is referenced. 443 | * 444 | * @param mixed $offset The offset to assign the value to. 445 | * @param mixed $value The value to set. 446 | * @link http://php.net/manual/en/arrayaccess.offsetset.php 447 | */ 448 | #[\ReturnTypeWillChange] 449 | public function offsetSet($offset, $value) { 450 | $this->getBody(); 451 | if (is_null($offset)) { 452 | $this->body[] = $value; 453 | } else { 454 | $this->body[$offset] = $value; 455 | } 456 | } 457 | 458 | /** 459 | * Unset an array offset. 460 | * 461 | * The is one of the methods of {@link \ArrayAccess} used to access this object as an array. 462 | * When using this object as an array the response body is referenced. 463 | * 464 | * @param mixed $offset The offset to unset. 465 | * @link http://php.net/manual/en/arrayaccess.offsetunset.php 466 | */ 467 | #[\ReturnTypeWillChange] 468 | public function offsetUnset($offset) { 469 | $this->getBody(); 470 | unset($this->body[$offset]); 471 | } 472 | 473 | /** 474 | * Get the request that generated this response. 475 | * 476 | * @return HttpRequest|null Returns the request. 477 | */ 478 | public function getRequest() 479 | { 480 | return $this->request; 481 | } 482 | 483 | /** 484 | * Set the request that corresponds to this response. 485 | * 486 | * @param HttpRequest $request The request that generated this response. 487 | * @return $this 488 | */ 489 | public function setRequest(HttpRequest $request = null) 490 | { 491 | $this->request = $request; 492 | return $this; 493 | } 494 | 495 | /** 496 | * Convert the response into an exception. 497 | * 498 | * @return HttpResponseException 499 | */ 500 | public function asException(): HttpResponseException 501 | { 502 | $request = $this->getRequest(); 503 | 504 | if ($request !== null) { 505 | $proxiedToUri = $request->getProxiedToUri(); 506 | $actualUri = $request->getUri(); 507 | 508 | $proxiedToUrl = $proxiedToUri !== null ? (string) $proxiedToUri : null; 509 | $proxiedThroughUrl = $proxiedToUrl !== null ? (string) $actualUri : null; 510 | $mainUrl = $proxiedToUrl ?? (string) $actualUri; 511 | 512 | if ($proxiedThroughUrl !== null) { 513 | $requestID = "Request \"{$request->getMethod()} {$mainUrl} (proxied through {$proxiedThroughUrl})\""; 514 | } else { 515 | $requestID = "Request \"{$request->getMethod()} {$mainUrl}\""; 516 | } 517 | } else { 518 | $requestID = "Unknown request"; 519 | } 520 | 521 | if ($this->isSuccessful()) { 522 | $responseAction = "returned a response code of {$this->getStatusCode()}"; 523 | } else { 524 | $responseAction = "failed with a response code of {$this->getStatusCode()}"; 525 | } 526 | 527 | $body = $this->getBody(); 528 | if ( 529 | is_array($body) && 530 | isset($body["message"]) && 531 | is_string($body["message"]) 532 | ) { 533 | $responseMessage = "and a custom message of \"{$body["message"]}\""; 534 | } else { 535 | $responseMessage = "and a standard message of \"{$this->getReasonPhrase()}\""; 536 | } 537 | 538 | $message = implode(" ", [ 539 | $requestID, 540 | $responseAction, 541 | $responseMessage, 542 | ]); 543 | 544 | return new HttpResponseException($this, $message); 545 | } 546 | 547 | /** 548 | * Basic JSON implementation. 549 | * 550 | * @return array 551 | */ 552 | public function jsonSerialize(): array 553 | { 554 | return [ 555 | "statusCode" => $this->getStatusCode(), 556 | "content-type" => $this->getHeader("content-type") ?: null, 557 | "request" => $this->getRequest(), 558 | "body" => $this->getRawBody(), 559 | "cf-ray" => $this->getHeader("cf-ray") ?: null, 560 | "cf-cache-status" => $this->getHeader("cf-cache-status") ?: null, 561 | ]; 562 | } 563 | 564 | /** 565 | * @inheritDoc 566 | */ 567 | public function withStatus($code, $reasonPhrase = "") 568 | { 569 | $cloned = clone $this; 570 | $cloned->setStatus($code); 571 | return $cloned; 572 | } 573 | } 574 | --------------------------------------------------------------------------------