diff --git a/composer.json b/composer.json index 448c473..f960133 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,14 @@ ] }, "require": { - "php": "^8.0", + "php": "^8.3", "guzzlehttp/guzzle": "^7.9", "guzzlehttp/psr7": "^2.7", - "psr/http-message": "^1.0|^2.0", - "jerome/matrix": "^2.0" + "illuminate/contracts": "^11.37", + "illuminate/events": "^11.37", + "illuminate/support": "^11.37", + "jerome/matrix": "^3.1", + "psr/http-message": "^1.0|^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.64", diff --git a/src/Fetch/Enum/Status.php b/src/Fetch/Enum/Status.php index 31b4f0a..ae94e5f 100644 --- a/src/Fetch/Enum/Status.php +++ b/src/Fetch/Enum/Status.php @@ -31,8 +31,15 @@ enum Status: int case UNSUPPORTED_MEDIA_TYPE = 415; case RANGE_NOT_SATISFIABLE = 416; case EXPECTATION_FAILED = 417; - case UPGRADE_REQUIRED = 426; + case IM_A_TEAPOT = 418; + case MISDIRECTED_REQUEST = 421; + case UNPROCESSABLE_ENTITY = 422; + case LOCKED = 423; + case FAILED_DEPENDENCY = 424; + case PRECONDITION_REQUIRED = 428; case TOO_MANY_REQUESTS = 429; + case REQUEST_HEADER_FIELDS_TOO_LARGE = 431; + case UNAVAILABLE_FOR_LEGAL_REASONS = 451; case INTERNAL_SERVER_ERROR = 500; case NOT_IMPLEMENTED = 501; case BAD_GATEWAY = 502; diff --git a/src/Fetch/Events/ConnectionFailed.php b/src/Fetch/Events/ConnectionFailed.php new file mode 100644 index 0000000..d9ed09b --- /dev/null +++ b/src/Fetch/Events/ConnectionFailed.php @@ -0,0 +1,32 @@ +request = $request; + $this->exception = $exception; + } +} diff --git a/src/Fetch/Events/RequestSending.php b/src/Fetch/Events/RequestSending.php new file mode 100644 index 0000000..043c9ca --- /dev/null +++ b/src/Fetch/Events/RequestSending.php @@ -0,0 +1,25 @@ +request = $request; + } +} diff --git a/src/Fetch/Events/ResponseReceived.php b/src/Fetch/Events/ResponseReceived.php new file mode 100644 index 0000000..172984c --- /dev/null +++ b/src/Fetch/Events/ResponseReceived.php @@ -0,0 +1,32 @@ +request = $request; + $this->response = $response; + } +} diff --git a/src/Fetch/Exceptions/ConnectionException.php b/src/Fetch/Exceptions/ConnectionException.php new file mode 100644 index 0000000..5b6b19a --- /dev/null +++ b/src/Fetch/Exceptions/ConnectionException.php @@ -0,0 +1,10 @@ +prepareMessage($response), + $response->status() + ); + + $this->response = $response; + } + + /** + * Enable truncation of request exception messages. + */ + public static function truncate(): void + { + static::$truncateAt = 120; + } + + /** + * Set the truncation length for request exception messages. + */ + public static function truncateAt(int $length): void + { + static::$truncateAt = $length; + } + + /** + * Disable truncation of request exception messages. + */ + public static function dontTruncate(): void + { + static::$truncateAt = false; + } + + /** + * Prepare the exception message. + */ + protected function prepareMessage(Response $response): string + { + $message = "HTTP request returned status code {$response->status()}"; + + $summary = static::$truncateAt + ? Message::bodySummary($response->toPsrResponse(), static::$truncateAt) + : Message::toString($response->toPsrResponse()); + + return is_null($summary) ? $message : $message .= ":\n{$summary}\n"; + } +} diff --git a/src/Fetch/Factory.php b/src/Fetch/Factory.php new file mode 100644 index 0000000..e603e38 --- /dev/null +++ b/src/Fetch/Factory.php @@ -0,0 +1,432 @@ +dispatcher = $dispatcher; + + $this->stubCallbacks = new Collection; + } + + /** + * Add middleware to apply to every request. + */ + public function globalMiddleware(callable $middleware): self + { + $this->globalMiddleware[] = $middleware; + + return $this; + } + + /** + * Add request middleware to apply to every request. + */ + public function globalRequestMiddleware(callable $middleware): self + { + $this->globalMiddleware[] = Middleware::mapRequest($middleware); + + return $this; + } + + /** + * Add response middleware to apply to every request. + */ + public function globalResponseMiddleware(callable $middleware): self + { + $this->globalMiddleware[] = Middleware::mapResponse($middleware); + + return $this; + } + + /** + * Set the options to apply to every request. + */ + public function globalOptions(Closure|array $options): self + { + $this->globalOptions = $options; + + return $this; + } + + /** + * Create a new response instance for use during stubbing. + */ + public static function response( + array|string|null $body = null, + int $status = Status::OK->value, + array $headers = [] + ): PromiseInterface { + if (is_array($body)) { + $body = json_encode($body); + + $headers['Content-Type'] = 'application/json'; + } + + $response = new Psr7Response($status, $headers, $body); + + return Create::promiseFor($response); + } + + /** + * Create a new connection exception for use during stubbing. + * + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public static function failedConnection(?string $message = null): callable + { + return function ($request) use ($message) { + return Create::rejectionFor(new ConnectException( + $message ?? "cURL error 6: Could not resolve host: {$request->toPsrRequest()->getUri()->getHost()} (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for {$request->toPsrRequest()->getUri()}.", + $request->toPsrRequest(), + )); + }; + } + + /** + * Get an invokable object that returns a sequence of responses in order for use during stubbing. + */ + public function sequence(array $responses = []): ResponseSequence + { + return $this->responseSequences[] = new ResponseSequence($responses); + } + + /** + * Register a stub callable that will intercept requests and be able to return stub responses. + */ + public function fake(callable|array|null $callback = null): self + { + $this->record(); + + $this->recorded = []; + + if (is_null($callback)) { + $callback = function () { + return static::response(); + }; + } + + if (is_array($callback)) { + foreach ($callback as $url => $callable) { + $this->stubUrl($url, $callable); + } + + return $this; + } + + $this->stubCallbacks = $this->stubCallbacks->merge(new Collection([ + function ($request, $options) use ($callback) { + $response = $callback; + + while ($response instanceof Closure) { + $response = $response($request, $options); + } + + if ($response instanceof PromiseInterface) { + $options['on_stats'](new TransferStats( + $request->toPsrRequest(), + $response->wait(), + )); + } + + return $response; + }, + ])); + + return $this; + } + + /** + * Register a response sequence for the given URL pattern. + */ + public function fakeSequence(string $url = '*'): ResponseSequence + { + return tap($this->sequence(), function ($sequence) use ($url) { + $this->fake([$url => $sequence]); + }); + } + + /** + * Stub the given URL using the given callback. + */ + public function stubUrl( + string $url, + Response|PromiseInterface|callable|int|string|array $callback + ): self { + return $this->fake(function ($request, $options) use ($url, $callback) { + if (! Str::is(Str::start($url, '*'), $request->url())) { + return; + } + + if (is_int($callback) && $callback >= 100 && $callback < 600) { + return static::response(status: $callback); + } + + if (is_int($callback) || is_string($callback)) { + return static::response($callback); + } + + if ($callback instanceof Closure || $callback instanceof ResponseSequence) { + return $callback($request, $options); + } + + return $callback; + }); + } + + /** + * Indicate that an exception should be thrown if any request is not faked. + */ + public function preventStrayRequests(bool $prevent = true): self + { + $this->preventStrayRequests = $prevent; + + return $this; + } + + /** + * Determine if stray requests are being prevented. + */ + public function preventingStrayRequests(): bool + { + return $this->preventStrayRequests; + } + + /** + * Indicate that an exception should not be thrown if any request is not faked. + */ + public function allowStrayRequests(): self + { + return $this->preventStrayRequests(false); + } + + /** + * Begin recording request / response pairs. + */ + protected function record(): self + { + $this->recording = true; + + return $this; + } + + /** + * Record a request response pair. + */ + public function recordRequestResponsePair( + Request $request, + ?Response $response + ): void { + if ($this->recording) { + $this->recorded[] = [$request, $response]; + } + } + + /** + * Assert that a request / response pair was recorded matching a given truth test. + */ + public function assertSent(callable $callback): void + { + PHPUnit::assertTrue( + $this->recorded($callback)->count() > 0, + 'An expected request was not recorded.' + ); + } + + /** + * Assert that the given request was sent in the given order. + */ + public function assertSentInOrder(array $callbacks): void + { + $this->assertSentCount(count($callbacks)); + + foreach ($callbacks as $index => $url) { + $callback = is_callable($url) ? $url : function ($request) use ($url) { + return $request->url() == $url; + }; + + PHPUnit::assertTrue($callback( + $this->recorded[$index][0], + $this->recorded[$index][1] + ), 'An expected request (#'.($index + 1).') was not recorded.'); + } + } + + /** + * Assert that a request / response pair was not recorded matching a given truth test. + */ + public function assertNotSent(callable $callback): void + { + PHPUnit::assertFalse( + $this->recorded($callback)->count() > 0, + 'Unexpected request was recorded.' + ); + } + + /** + * Assert that no request / response pair was recorded. + */ + public function assertNothingSent(): void + { + PHPUnit::assertEmpty( + $this->recorded, + 'Requests were recorded.' + ); + } + + /** + * Assert how many requests have been recorded. + */ + public function assertSentCount(int $count): void + { + PHPUnit::assertCount($count, $this->recorded); + } + + /** + * Assert that every created response sequence is empty. + */ + public function assertSequencesAreEmpty(): void + { + foreach ($this->responseSequences as $responseSequence) { + PHPUnit::assertTrue( + $responseSequence->isEmpty(), + 'Not all response sequences are empty.' + ); + } + } + + /** + * Get a collection of the request / response pairs matching the given truth test. + */ + public function recorded(?callable $callback = null): Collection + { + if (empty($this->recorded)) { + return new Collection; + } + + $callback = $callback ?: function () { + return true; + }; + + return (new Collection($this->recorded)) + ->filter(fn ($pair) => $callback($pair[0], $pair[1])); + } + + /** + * Create a new pending request instance for this factory. + */ + public function createPendingRequest(): PendingRequest + { + return tap($this->newPendingRequest(), function ($request) { + $request->stub($this->stubCallbacks) + ->preventStrayRequests($this->preventStrayRequests); + }); + } + + /** + * Instantiate a new pending request instance for this factory. + */ + protected function newPendingRequest(): PendingRequest + { + return (new PendingRequest($this, $this->globalMiddleware)) + ->withOptions(value($this->globalOptions)); + } + + /** + * Get the current event dispatcher implementation. + */ + public function getDispatcher(): ?Dispatcher + { + return $this->dispatcher; + } + + /** + * Get the array of global middleware. + */ + public function getGlobalMiddleware(): array + { + return $this->globalMiddleware; + } + + /** + * Execute a method against a new pending request instance. + */ + public function __call(string $method, array $parameters): mixed + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + return $this->createPendingRequest()->{$method}(...$parameters); + } +} diff --git a/src/Fetch/Http/ClientHandler.php b/src/Fetch/Http/ClientHandler.php deleted file mode 100644 index d717028..0000000 --- a/src/Fetch/Http/ClientHandler.php +++ /dev/null @@ -1,455 +0,0 @@ - 'GET', - 'headers' => [], - 'timeout' => self::DEFAULT_TIMEOUT, - ]; - - /** - * ClientHandler constructor. - * - * @param ClientInterface|null $syncClient The synchronous HTTP client. - * @param array $options The options for the request. - * @param int|null $timeout Timeout for the request. - * @param int|null $retries Number of retries for the request. - * @param int|null $retryDelay Delay between retries. - * @param bool $isAsync Whether the request is asynchronous. - * @return void - */ - public function __construct( - protected ?ClientInterface $syncClient = null, - protected array $options = [], - protected ?int $timeout = null, - protected ?int $retries = null, - protected ?int $retryDelay = null, - protected bool $isAsync = false - ) {} - - /** - * Apply options and execute the request. - */ - public static function handle(string $method, string $uri, array $options = []): mixed - { - $handler = new static; - $handler->applyOptions($options); - - return $handler->finalizeRequest($method, $uri); - } - - /** - * Apply the options to the handler. - */ - protected function applyOptions(array $options): void - { - if (isset($options['client'])) { - $this->setSyncClient($options['client']); - } - - $this->options = array_merge($this->options, $options); - - $this->timeout = $options['timeout'] ?? $this->timeout; - $this->retries = $options['retries'] ?? $this->retries; - $this->retryDelay = $options['retry_delay'] ?? $this->retryDelay; - $this->isAsync = ! empty($options['async']); - - if (isset($options['base_uri'])) { - $this->baseUri($options['base_uri']); - } - } - - /** - * Finalize the request and send it. - */ - protected function finalizeRequest(string $method, string $uri): mixed - { - $this->options['method'] = $method; - $this->options['uri'] = $uri; - - $this->mergeOptionsAndProperties(); - - return $this->isAsync ? $this->sendAsync() : $this->sendSync(); - } - - /** - * Merge class properties and options into the final options array. - */ - protected function mergeOptionsAndProperties(): void - { - $this->options['timeout'] = $this->timeout ?? self::DEFAULT_TIMEOUT; - $this->options['retries'] = $this->retries ?? self::DEFAULT_RETRIES; - $this->options['retry_delay'] = $this->retryDelay ?? self::DEFAULT_RETRY_DELAY; - } - - /** - * Send a synchronous HTTP request. - */ - protected function sendSync(): ResponseInterface - { - return $this->retryRequest(function (): ResponseInterface { - $psrResponse = $this->getSyncClient()->request( - $this->options['method'], - $this->getFullUri(), - $this->options - ); - - return Response::createFromBase($psrResponse); - }); - } - - /** - * Send an asynchronous HTTP request. - */ - protected function sendAsync(): AsyncHelperInterface - { - return new AsyncHelper( - promise: fn (): ResponseInterface => $this->sendSync() - ); - } - - /** - * Implement retry logic for the request with exponential backoff. - */ - protected function retryRequest(callable $request): ResponseInterface - { - $attempts = $this->retries ?? self::DEFAULT_RETRIES; - $delay = $this->retryDelay ?? self::DEFAULT_RETRY_DELAY; - - for ($i = 0; $i < $attempts; $i++) { - try { - return $request(); - } catch (RequestException $e) { - if ($i === $attempts - 1) { - throw $e; // Rethrow if all retries failed - } - usleep($delay * 1000); // Convert milliseconds to microseconds - } - } - - throw new RuntimeException('Request failed after all retries.'); - } - - /** - * Determine if an error is retryable. - */ - protected function isRetryableError(RequestException $e): bool - { - return in_array($e->getCode(), [500, 502, 503, 504]); - } - - /** - * Get the full URI for the request. - */ - protected function getFullUri(): string - { - $baseUri = $this->options['base_uri'] ?? ''; - $uri = $this->options['uri'] ?? ''; - - // If the URI is an absolute URL, return it as is - if (filter_var($uri, \FILTER_VALIDATE_URL)) { - return $uri; - } - - // If base URI is empty, return the URI with leading slashes trimmed - if (empty($baseUri)) { - return ltrim($uri, '/'); - } - - // Ensure base URI is a valid URL - if (! filter_var($baseUri, \FILTER_VALIDATE_URL)) { - throw new InvalidArgumentException("Invalid base URI: $baseUri"); - } - - // Concatenate base URI and URI ensuring no double slashes - return rtrim($baseUri, '/') . '/' . ltrim($uri, '/'); - } - - /** - * Reset the handler state. - */ - public function reset(): self - { - $this->options = []; - $this->timeout = null; - $this->retries = null; - $this->retryDelay = null; - $this->isAsync = false; - - return $this; - } - - /** - * Get the synchronous HTTP client. - */ - public function getSyncClient(): ClientInterface - { - if (! $this->syncClient) { - $this->syncClient = new SyncClient; - } - - return $this->syncClient; - } - - /** - * Set the synchronous HTTP client. - */ - public function setSyncClient(ClientInterface $syncClient): self - { - $this->syncClient = $syncClient; - - return $this; - } - - /** - * Get the default options for the request. - */ - public static function getDefaultOptions(): array - { - return self::$defaultOptions; - } - - /** - * Set the base URI for the request. - */ - public function baseUri(string $baseUri): self - { - $this->options['base_uri'] = $baseUri; - - return $this; - } - - /** - * Set the token for the request. - */ - public function withToken(string $token): self - { - $this->options['headers']['Authorization'] = 'Bearer ' . $token; - - return $this; - } - - /** - * Set the basic auth for the request. - */ - public function withAuth(string $username, string $password): self - { - $this->options['auth'] = [$username, $password]; - - return $this; - } - - /** - * Set the headers for the request. - */ - public function withHeaders(array $headers): self - { - $this->options['headers'] = array_merge( - $this->options['headers'] ?? [], - $headers - ); - - return $this; - } - - /** - * Set the body for the request. - */ - public function withBody(array $body): self - { - $this->options['body'] = json_encode($body); - - return $this; - } - - /** - * Set the query parameters for the request. - */ - public function withQueryParameters(array $queryParams): self - { - $this->options['query'] = $queryParams; - - return $this; - } - - /** - * Set the timeout for the request. - */ - public function timeout(int $seconds): self - { - $this->timeout = $seconds; - - return $this; - } - - /** - * Set the retry logic for the request. - */ - public function retry(int $retries, int $delay = 100): self - { - $this->retries = $retries; - $this->retryDelay = $delay; - - return $this; - } - - /** - * Set the request to be asynchronous or not. - */ - public function async(?bool $async = true): self - { - $this->isAsync = $async; - - return $this; - } - - /** - * Set the proxy for the request. - */ - public function withProxy(string|array $proxy): self - { - $this->options['proxy'] = $proxy; - - return $this; - } - - /** - * Set the cookies for the request. - */ - public function withCookies(bool|CookieJarInterface $cookies): self - { - $this->options['cookies'] = $cookies; - - return $this; - } - - /** - * Set whether to follow redirects. - */ - public function withRedirects(bool|array $redirects = true): self - { - $this->options['allow_redirects'] = $redirects; - - return $this; - } - - /** - * Set the certificate for the request. - */ - public function withCert(string|array $cert): self - { - $this->options['cert'] = $cert; - - return $this; - } - - /** - * Set the SSL key for the request. - */ - public function withSslKey(string|array $sslKey): self - { - $this->options['ssl_key'] = $sslKey; - - return $this; - } - - /** - * Set the stream option for the request. - */ - public function withStream(bool $stream): self - { - $this->options['stream'] = $stream; - - return $this; - } - - /** - * Finalize and send a GET request. - */ - public function get(string $uri): mixed - { - return $this->finalizeRequest('GET', $uri); - } - - /** - * Finalize and send a POST request. - */ - public function post(string $uri, mixed $body = null): mixed - { - if ($body !== null) { - $this->withBody($body); - } - - return $this->finalizeRequest('POST', $uri); - } - - /** - * Finalize and send a PUT request. - */ - public function put(string $uri, mixed $body = null): mixed - { - if ($body !== null) { - $this->withBody($body); - } - - return $this->finalizeRequest('PUT', $uri); - } - - /** - * Finalize and send a DELETE request. - */ - public function delete(string $uri): mixed - { - return $this->finalizeRequest('DELETE', $uri); - } - - /** - * Finalize and send an OPTIONS request. - */ - public function options(string $uri): mixed - { - return $this->finalizeRequest('OPTIONS', $uri); - } - - /** - * Indicate that the request is asynchronous. - */ - public function isAsync(): bool - { - return $this->isAsync; - } -} diff --git a/src/Fetch/Http/Response.php b/src/Fetch/Http/Response.php deleted file mode 100644 index 19da7bf..0000000 --- a/src/Fetch/Http/Response.php +++ /dev/null @@ -1,154 +0,0 @@ -bodyContents = (string) $body; - } - - /** - * Get the body as a JSON-decoded array or object. - * - * @param bool $assoc Whether to return associative array (true) or object (false) - * @return mixed - */ - public function json(bool $assoc = true, bool $throwOnError = true) - { - $decoded = json_decode($this->bodyContents, $assoc); - $jsonError = json_last_error(); - - if ($jsonError === \JSON_ERROR_NONE) { - return $decoded; - } - - if ($throwOnError) { - throw new RuntimeException('Failed to decode JSON: ' . json_last_error_msg()); - } - - return null; // or return an empty array/object depending on your needs. - } - - /** - * Get the body as plain text. - */ - public function text(): string - { - return $this->bodyContents; - } - - /** - * Get the body as a stream (simulating a "blob" in JavaScript). - * - * @return resource|false - */ - public function blob() - { - $stream = fopen('php://memory', 'r+'); - - if ($stream === false) { - return false; - } - fwrite($stream, $this->bodyContents); - rewind($stream); - - return $stream; - } - - /** - * Get the body as an array buffer (binary data). - */ - public function arrayBuffer(): string - { - return $this->bodyContents; - } - - /** - * Get the status text for the response (e.g., "OK"). - */ - public function statusText(): string - { - return $this->getReasonPhrase() ?: 'No reason phrase available'; - } - - /** - * Create a new response from a base response. - * - * Note: The response body will be fully read into memory. - */ - public static function createFromBase(PsrResponseInterface $response): self - { - return new self( - $response->getStatusCode(), - $response->getHeaders(), - (string) $response->getBody(), - $response->getProtocolVersion(), - $response->getReasonPhrase() - ); - } - - /** - * Check if the response status code is informational (1xx). - */ - public function isInformational(): bool - { - return $this->getStatusCode() >= 100 && $this->getStatusCode() < 200; - } - - /** - * Check if the response status code is OK (2xx). - */ - public function ok(): bool - { - return $this->getStatusCode() >= 200 && $this->getStatusCode() < 300; - } - - /** - * Check if the response status code is a redirection (3xx). - */ - public function isRedirection(): bool - { - return $this->getStatusCode() >= 300 && $this->getStatusCode() < 400; - } - - /** - * Check if the response status code is a client error (4xx). - */ - public function isClientError(): bool - { - return $this->getStatusCode() >= 400 && $this->getStatusCode() < 500; - } - - /** - * Check if the response status code is a server error (5xx). - */ - public function isServerError(): bool - { - return $this->getStatusCode() >= 500 && $this->getStatusCode() < 600; - } -} diff --git a/src/Fetch/Http/fetch.php b/src/Fetch/Http/fetch.php deleted file mode 100644 index 53a7788..0000000 --- a/src/Fetch/Http/fetch.php +++ /dev/null @@ -1,48 +0,0 @@ -hasResponse()) { - return Response::createFromBase($e->getResponse()); - } - - throw $e; // Rethrow for other unhandled errors - } - } -} diff --git a/src/Fetch/Interfaces/ClientHandler.php b/src/Fetch/Interfaces/ClientHandler.php deleted file mode 100644 index 97b5d46..0000000 --- a/src/Fetch/Interfaces/ClientHandler.php +++ /dev/null @@ -1,116 +0,0 @@ -status() === 200; + } + + /** + * Determine if the response code was 201 "Created" response. + */ + public function created(): bool + { + return $this->status() === 201; + } + + /** + * Determine if the response code was 202 "Accepted" response. + */ + public function accepted(): bool + { + return $this->status() === 202; + } + + /** + * Determine if the response code was the given status code and the body has no content. + */ + public function noContent(int $status = 204): bool + { + return $this->status() === $status && $this->body() === ''; + } + + /** + * Determine if the response code was a 301 "Moved Permanently". + */ + public function movedPermanently(): bool + { + return $this->status() === 301; + } + + /** + * Determine if the response code was a 302 "Found" response. + */ + public function found(): bool + { + return $this->status() === 302; + } + + /** + * Determine if the response code was a 304 "Not Modified" response. + */ + public function notModified(): bool + { + return $this->status() === 304; + } + + /** + * Determine if the response was a 400 "Bad Request" response. + */ + public function badRequest(): bool + { + return $this->status() === 400; + } + + /** + * Determine if the response was a 401 "Unauthorized" response. + */ + public function unauthorized(): bool + { + return $this->status() === 401; + } + + /** + * Determine if the response was a 402 "Payment Required" response. + */ + public function paymentRequired(): bool + { + return $this->status() === 402; + } + + /** + * Determine if the response was a 403 "Forbidden" response. + */ + public function forbidden(): bool + { + return $this->status() === 403; + } + + /** + * Determine if the response was a 404 "Not Found" response. + */ + public function notFound(): bool + { + return $this->status() === 404; + } + + /** + * Determine if the response was a 408 "Request Timeout" response. + */ + public function requestTimeout(): bool + { + return $this->status() === 408; + } + + /** + * Determine if the response was a 409 "Conflict" response. + */ + public function conflict(): bool + { + return $this->status() === 409; + } + + /** + * Determine if the response was a 422 "Unprocessable Content" response. + */ + public function unprocessableContent(): bool + { + return $this->status() === 422; + } + + /** + * Determine if the response was a 422 "Unprocessable Content" response. + */ + public function unprocessableEntity(): bool + { + return $this->unprocessableContent(); + } + + /** + * Determine if the response was a 429 "Too Many Requests" response. + */ + public function tooManyRequests(): bool + { + return $this->status() === 429; + } +} diff --git a/tests/Integration/HttpTest.php b/tests/Integration/HttpTest.php deleted file mode 100644 index 5aaf077..0000000 --- a/tests/Integration/HttpTest.php +++ /dev/null @@ -1,189 +0,0 @@ -shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['success' => true]))); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->get('https://example.com'); - - expect($response->json())->toBe(['success' => true]); - expect($response->getStatusCode())->toBe(200); -}); - -/* - * Test for a successful asynchronous GET request. - */ -test('makes a successful asynchronous GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['async' => 'result']))); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - async(fn () => $clientHandler->get('https://example.com')) - ->then(function (Response $response) { - expect($response->json())->toBe(['async' => 'result']); - expect($response->getStatusCode())->toBe(200); - }); -}); - -/* - * Test for sending headers with a GET request. - */ -test('sends headers with a GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::on(function ($options) { - return $options['headers']['Authorization'] === 'Bearer token'; - })) - ->andReturn(new Response(200, [], 'Headers checked')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->withHeaders(['Authorization' => 'Bearer token']) - ->get('https://example.com'); - - expect($response->text())->toBe('Headers checked'); -}); - -/* - * Test for sending query parameters with a GET request. - */ -test('appends query parameters to the GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::on(function ($options) { - return $options['query'] === ['foo' => 'bar', 'baz' => 'qux']; - })) - ->andReturn(new Response(200, [], 'Query params checked')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->withQueryParameters(['foo' => 'bar', 'baz' => 'qux']) - ->get('https://example.com'); - - expect($response->text())->toBe('Query params checked'); -}); - -/* - * Test for handling timeouts in synchronous requests. - */ -test('handles timeout for synchronous requests', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::on(function ($options) { - return $options['timeout'] === 1; - })) - ->andThrow(new RequestException('Timeout', new Request('GET', 'https://example.com'))); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - expect(fn () => $clientHandler->timeout(1)->get('https://example.com')) - ->toThrow(RequestException::class, 'Timeout'); -}); - -/* - * Test for retry mechanism in synchronous requests. - */ -test('retries a failed synchronous request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andThrow(new RequestException('Failed request', new Request('GET', 'https://example.com'))); - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], 'Success after retry')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->retry(2, 100)->get('https://example.com'); - - expect($response->text())->toBe('Success after retry'); -}); - -/* - * Test for making a POST request with body data. - */ -test('makes a POST request with body data', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('POST', 'https://example.com/users', \Mockery::on(function ($options) { - return $options['body'] === json_encode(['name' => 'John']); - })) - ->andReturn(new Response(201, [], 'Created')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->withBody(['name' => 'John']) - ->post('https://example.com/users'); - - expect($response->getStatusCode())->toBe(201); - expect($response->text())->toBe('Created'); -}); - -/* - * Test for retry mechanism in asynchronous requests. - */ -test('retries an asynchronous request on failure', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andThrow(new RequestException('Failed request', new Request('GET', 'https://example.com'))); - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], 'Success after retry')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - async(fn () => $clientHandler->retry(2, 100)->get('https://example.com')) - ->then(function (Response $response) { - expect($response->text())->toBe('Success after retry'); - }); -}); diff --git a/tests/Unit/ClientHandlerTest.php b/tests/Unit/ClientHandlerTest.php deleted file mode 100644 index 1c2e451..0000000 --- a/tests/Unit/ClientHandlerTest.php +++ /dev/null @@ -1,252 +0,0 @@ -shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['success' => true]))); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->get('https://example.com'); - - // Since we are using Fetch\Http\Response, we check the json method - expect($response->json())->toBe(['success' => true]); - expect($response->getStatusCode())->toBe(200); -}); - -test('makes successful synchronous GET request using fluent API', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'http://localhost/', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['success' => true]))); - }); - - $clientHandler = new ClientHandler; - $response = $clientHandler->setSyncClient($mockClient) - ->baseUri('http://localhost') - ->get('/'); - - expect($response->json())->toBe(['success' => true]); - expect($response->getStatusCode())->toBe(200); -}); - -/* - * Test for a successful asynchronous GET request. - */ -test('makes a successful asynchronous GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['async' => 'result']))); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - // Directly invoke the async method and interact with the AsyncHelper instance - $clientHandler->async()->get('https://example.com') - ->then(function (Response $response) { - expect($response->json())->toBe(['async' => 'result']); - expect($response->getStatusCode())->toBe(200); - }) - ->catch(function (\Throwable $e) { - throw $e; // Fail the test if an exception is caught - }); -}); - -test('makes successful synchronous POST request using fluent API', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('POST', 'http://localhost/posts', \Mockery::type('array')) - ->andReturn(new Response(201, [], json_encode(['success' => true]))); - }); - - $clientHandler = new ClientHandler; - $response = $clientHandler->setSyncClient($mockClient) - ->baseUri('http://localhost') - ->withHeaders(['Content-Type' => 'application/json']) - ->withBody(['key' => 'value']) - ->withToken('fake-bearer-auth-token') - ->post('/posts'); - - expect($response->json())->toBe(['success' => true]); - expect($response->getStatusCode())->toBe(201); -}); - -/* - * Test for sending headers with a GET request. - */ -test('sends headers with a GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::on(function ($options) { - return $options['headers']['Authorization'] === 'Bearer token'; - })) - ->andReturn(new Response(200, [], 'Headers checked')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->withHeaders(['Authorization' => 'Bearer token']) - ->get('https://example.com'); - - expect($response->text())->toBe('Headers checked'); -}); - -/* - * Test for sending query parameters with a GET request. - */ -test('appends query parameters to the GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::on(function ($options) { - return $options['query'] === ['foo' => 'bar', 'baz' => 'qux']; - })) - ->andReturn(new Response(200, [], 'Query params checked')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->withQueryParameters(['foo' => 'bar', 'baz' => 'qux']) - ->get('https://example.com'); - - expect($response->text())->toBe('Query params checked'); -}); - -/* - * Test for handling timeouts in synchronous requests. - */ -test('handles timeout for synchronous requests', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'https://example.com', \Mockery::on(function ($options) { - return $options['timeout'] === 1; - })) - ->andThrow(new RequestException('Timeout', new Request('GET', 'https://example.com'))); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - try { - $clientHandler->timeout(1)->get('https://example.com'); - } catch (RequestException $e) { - expect($e->getMessage())->toContain('Timeout'); - } -}); - -/* - * Test for retry mechanism in synchronous requests. - */ -test('retries a failed synchronous request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->times(1) // Expecting 2 calls: 1 failed, 1 retry - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andThrow(new RequestException('Failed request', new Request('GET', 'https://example.com'))); - $mock->shouldReceive('request') - ->times(1) // Expecting 2 calls: 1 failed, 1 retry - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], 'Success after retry')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->retry(2)->get('https://example.com'); // Retry once on failure - - expect($response->text())->toBe('Success after retry'); -}); - -/* - * Test for making a POST request with body data. - */ -test('makes a POST request with body data', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('POST', 'https://example.com/users', \Mockery::on(function ($options) { - return $options['body'] === json_encode(['name' => 'John']); - })) - ->andReturn(new Response(201, [], 'Created')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - $response = $clientHandler->withBody(['name' => 'John']) - ->post('https://example.com/users'); - - expect($response->getStatusCode())->toBe(201); - expect($response->text())->toBe('Created'); -}); - -/* - * Test for retry mechanism in asynchronous requests. - */ -test('retries an asynchronous request on failure', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andThrow(new RequestException('Failed request', new Request('GET', 'https://example.com'))); - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'https://example.com', \Mockery::type('array')) - ->andReturn(new Response(200, [], 'Success after retry')); - }); - - $clientHandler = new ClientHandler; - $clientHandler->setSyncClient($mockClient); - - async(fn () => $clientHandler->retry(2)->get('https://example.com')) - ->then(function (Response $response) { - expect($response->text())->toBe('Success after retry'); - }) - ->catch(function (\Throwable $e) { - throw $e; // Fail the test if an exception is caught - }); -}); - -test('checks if the request is asynchronous', function () { - $clientHandler = new ClientHandler; - - // Initially, the request should not be asynchronous - expect($clientHandler->isAsync())->toBe(false); - - // Simulate setting the request to asynchronous - $clientHandler->async(); - expect($clientHandler->isAsync())->toBe(true); - - // Simulate setting the request back to synchronous - $clientHandler->async(false); - expect($clientHandler->isAsync())->toBe(false); -}); diff --git a/tests/Unit/FetchTest.php b/tests/Unit/FetchTest.php deleted file mode 100644 index 2dee118..0000000 --- a/tests/Unit/FetchTest.php +++ /dev/null @@ -1,200 +0,0 @@ -shouldReceive('request') - ->once() - ->with('GET', 'http://localhost', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['success' => true]))); - }); - - $response = fetch('http://localhost', ['client' => $mockClient]); - - expect($response->json())->toBe(['success' => true]); - expect($response->getStatusCode())->toBe(200); -}); - -/* - * Test for a successful asynchronous GET request using fetch. - */ -test('fetch makes a successful asynchronous GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'http://localhost', \Mockery::type('array')) - ->andReturn(new Response(200, [], json_encode(['async' => 'result']))); - }); - - async(fn () => fetch('http://localhost', ['client' => $mockClient])) - ->then(function (Response $response) { - expect($response->json())->toBe(['async' => 'result']); - expect($response->getStatusCode())->toBe(200); - }) - ->catch(function (\Throwable $e) { - throw $e; - }); -}); - -test('fetch makes successful synchronous POST request using fluent API', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('POST', 'http://localhost/posts', \Mockery::type('array')) - ->andReturn(new Response(201, [], json_encode(['success' => true]))); - }); - - $response = fetch() - ->setSyncClient($mockClient) // Set the mock client - ->baseUri('http://localhost') - ->withHeaders(['Content-Type' => 'application/json']) - ->withBody(['key' => 'value']) - ->withToken('fake-bearer-auth-token') - ->post('/posts'); - - expect($response->json())->toBe(['success' => true]); - expect($response->getStatusCode())->toBe(201); -}); - -/* - * Test for sending headers with a GET request using fetch. - */ -test('fetch sends headers with a GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'http://localhost', \Mockery::on(function ($options) { - return $options['headers']['Authorization'] === 'Bearer token'; - })) - ->andReturn(new Response(200, [], 'Headers checked')); - }); - - $response = fetch('http://localhost', [ - 'headers' => ['Authorization' => 'Bearer token'], - 'client' => $mockClient, - ]); - - expect($response->text())->toBe('Headers checked'); -}); - -/* - * Test for sending query parameters with a GET request using fetch. - */ -test('fetch appends query parameters to the GET request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'http://localhost', \Mockery::on(function ($options) { - return $options['query'] === ['foo' => 'bar', 'baz' => 'qux']; - })) - ->andReturn(new Response(200, [], 'Query params checked')); - }); - - $response = fetch('http://localhost', [ - 'query' => ['foo' => 'bar', 'baz' => 'qux'], - 'client' => $mockClient, - ]); - - expect($response->text())->toBe('Query params checked'); -}); - -/* - * Test for handling timeouts in synchronous requests using fetch. - */ -test('fetch handles timeout for synchronous requests', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('GET', 'http://localhost', \Mockery::on(function ($options) { - return $options['timeout'] === 1; - })) - ->andThrow(new RequestException('Timeout', new Request('GET', 'http://localhost'))); - }); - - try { - fetch('http://localhost', ['timeout' => 1, 'client' => $mockClient]); - } catch (RequestException $e) { - expect($e->getMessage())->toContain('Timeout'); - } -}); - -/* - * Test for retry mechanism in fetch requests. - */ -test('fetch retries a failed synchronous request', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->times(1) // Expecting 2 calls: 1 failed, 1 retry - ->with('GET', 'http://localhost', \Mockery::type('array')) - ->andThrow(new RequestException('Failed request', new Request('GET', 'http://localhost'))); - $mock->shouldReceive('request') - ->times(1) // Expecting 2 calls: 1 failed, 1 retry - ->with('GET', 'http://localhost', \Mockery::type('array')) - ->andReturn(new Response(200, [], 'Success after retry')); - }); - - $response = fetch('http://localhost', ['retries' => 2, 'client' => $mockClient]); - - expect($response->text())->toBe('Success after retry'); -}); - -/* - * Test for making a POST request with body data using fetch. - */ -test('fetch makes a POST request with body data', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->once() - ->with('POST', 'http://localhost/users', \Mockery::on(function ($options) { - return $options['body'] === json_encode(['name' => 'John']); - })) - ->andReturn(new Response(201, [], 'Created')); - }); - - $response = fetch('http://localhost/users', [ - 'method' => 'POST', - 'body' => json_encode(['name' => 'John']), - 'client' => $mockClient, - ]); - - expect($response->getStatusCode())->toBe(201); - expect($response->text())->toBe('Created'); -}); - -/* - * Test for retry mechanism in asynchronous requests using fetch. - */ -test('fetch retries an asynchronous request on failure', function () { - $mockClient = mock(Client::class, function (MockInterface $mock) { - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'http://localhost', \Mockery::type('array')) - ->andThrow(new RequestException('Failed request', new Request('GET', 'http://localhost'))); - $mock->shouldReceive('request') - ->times(1) - ->with('GET', 'http://localhost', \Mockery::type('array')) - ->andReturn(new Response(200, [], 'Success after retry')); - }); - - async(fn () => fetch('http://localhost', ['retries' => 2, 'client' => $mockClient])) - ->then(function (Response $response) { - expect($response->text())->toBe('Success after retry'); - }) - ->catch(function (\Throwable $e) { - throw $e; // Fail the test if an exception is caught - }); -}); diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php deleted file mode 100644 index f125712..0000000 --- a/tests/Unit/ResponseTest.php +++ /dev/null @@ -1,102 +0,0 @@ - 'application/json'], '{"key":"value"}'); - $response = new Response( - $guzzleResponse->getStatusCode(), - $guzzleResponse->getHeaders(), - (string) $guzzleResponse->getBody(), - $guzzleResponse->getProtocolVersion(), - $guzzleResponse->getReasonPhrase() - ); - - $json = $response->json(); - expect($json)->toMatchArray(['key' => 'value']); -}); - -test('Response::text() correctly retrieves plain text', function () { - $guzzleResponse = new GuzzleResponse(200, [], 'Plain text content'); - $response = new Response( - $guzzleResponse->getStatusCode(), - $guzzleResponse->getHeaders(), - (string) $guzzleResponse->getBody(), - $guzzleResponse->getProtocolVersion(), - $guzzleResponse->getReasonPhrase() - ); - - expect($response->text())->toBe('Plain text content'); -}); - -test('Response::blob() correctly retrieves blob (stream)', function () { - $guzzleResponse = new GuzzleResponse(200, [], 'Binary data'); - $response = new Response( - $guzzleResponse->getStatusCode(), - $guzzleResponse->getHeaders(), - (string) $guzzleResponse->getBody(), - $guzzleResponse->getProtocolVersion(), - $guzzleResponse->getReasonPhrase() - ); - - $blob = $response->blob(); - expect(is_resource($blob))->toBeTrue(); -}); - -test('Response::arrayBuffer() correctly retrieves binary data as string', function () { - $guzzleResponse = new GuzzleResponse(200, [], 'Binary data'); - $response = new Response( - $guzzleResponse->getStatusCode(), - $guzzleResponse->getHeaders(), - (string) $guzzleResponse->getBody(), - $guzzleResponse->getProtocolVersion(), - $guzzleResponse->getReasonPhrase() - ); - - expect($response->arrayBuffer())->toBe('Binary data'); -}); - -test('Response::statusText() correctly retrieves status text', function () { - $guzzleResponse = new GuzzleResponse(200); - $response = new Response( - $guzzleResponse->getStatusCode(), - $guzzleResponse->getHeaders(), - (string) $guzzleResponse->getBody(), - $guzzleResponse->getProtocolVersion(), - $guzzleResponse->getReasonPhrase() - ); - - expect($response->statusText())->toBe('OK'); -}); - -test('Response status helper methods work correctly', function () { - $informationalResponse = new Response(100); - $successfulResponse = new Response(200); - $redirectionResponse = new Response(301); - $clientErrorResponse = new Response(404); - $serverErrorResponse = new Response(500); - - expect($informationalResponse->isInformational())->toBeTrue(); - expect($successfulResponse->ok())->toBeTrue(); - expect($redirectionResponse->isRedirection())->toBeTrue(); - expect($clientErrorResponse->isClientError())->toBeTrue(); - expect($serverErrorResponse->isServerError())->toBeTrue(); -}); - -test('Response handles error gracefully', function () { - $errorMessage = 'Something went wrong'; - $guzzleResponse = new GuzzleResponse(500, [], $errorMessage); - $response = new Response( - $guzzleResponse->getStatusCode(), - $guzzleResponse->getHeaders(), - (string) $guzzleResponse->getBody(), - $guzzleResponse->getProtocolVersion(), - $guzzleResponse->getReasonPhrase() - ); - - expect($response->getStatusCode())->toBe(500); - expect($response->text())->toBe($errorMessage); -});