diff --git a/src/Chunk.php b/src/Chunk.php new file mode 100644 index 0000000..a644190 --- /dev/null +++ b/src/Chunk.php @@ -0,0 +1,65 @@ +data; + } + + /** + * Get the size of the chunk in bytes + * + * @return int + */ + public function getSize(): int + { + return $this->size; + } + + /** + * Get the timestamp when the chunk was received + * + * @return float + */ + public function getTimestamp(): float + { + return $this->timestamp; + } + + /** + * Get the sequential index of this chunk + * + * @return int + */ + public function getIndex(): int + { + return $this->index; + } +} diff --git a/src/Client.php b/src/Client.php index e52da87..80daa27 100644 --- a/src/Client.php +++ b/src/Client.php @@ -199,6 +199,7 @@ private function withRetries(callable $callback): mixed * @param string $method * @param array|array $body * @param array $query + * @param ?callable $chunks Optional callback function that receives a Chunk object * @return Response */ public function fetch( @@ -206,9 +207,10 @@ public function fetch( string $method = self::METHOD_GET, ?array $body = [], ?array $query = [], + ?callable $chunks = null, ): Response { if (!in_array($method, [self::METHOD_PATCH, self::METHOD_GET, self::METHOD_CONNECT, self::METHOD_DELETE, self::METHOD_POST, self::METHOD_HEAD, self::METHOD_OPTIONS, self::METHOD_PUT, self::METHOD_TRACE])) { - throw new FetchException("Unsupported HTTP method"); + throw new Exception("Unsupported HTTP method"); } if (isset($this->headers['content-type']) && $body !== null) { @@ -229,6 +231,8 @@ public function fetch( } $responseHeaders = []; + $responseBody = ''; + $chunkIndex = 0; $ch = curl_init(); $curlOptions = [ CURLOPT_URL => $url, @@ -244,11 +248,24 @@ public function fetch( $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); return $len; }, + CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($chunks, &$responseBody, &$chunkIndex) { + if ($chunks !== null) { + $chunk = new Chunk( + data: $data, + size: strlen($data), + timestamp: microtime(true), + index: $chunkIndex++ + ); + $chunks($chunk); + } else { + $responseBody .= $data; + } + return strlen($data); + }, CURLOPT_CONNECTTIMEOUT => $this->connectTimeout, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_MAXREDIRS => $this->maxRedirects, CURLOPT_FOLLOWLOCATION => $this->allowRedirects, - CURLOPT_RETURNTRANSFER => true, CURLOPT_USERAGENT => $this->userAgent ]; @@ -257,21 +274,19 @@ public function fetch( curl_setopt($ch, $option, $value); } - $sendRequest = function () use ($ch, &$responseHeaders) { + $sendRequest = function () use ($ch, &$responseHeaders, &$responseBody) { $responseHeaders = []; - $responseBody = curl_exec($ch); - $responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if (curl_errno($ch)) { + $success = curl_exec($ch); + if ($success === false) { $errorMsg = curl_error($ch); + curl_close($ch); + throw new Exception($errorMsg); } + $responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if (isset($errorMsg)) { - throw new FetchException($errorMsg); - } - return new Response( statusCode: $responseStatusCode, headers: $responseHeaders, diff --git a/src/FetchException.php b/src/Exception.php similarity index 90% rename from src/FetchException.php rename to src/Exception.php index ac6a9bd..29a3f1b 100644 --- a/src/FetchException.php +++ b/src/Exception.php @@ -2,7 +2,7 @@ namespace Utopia\Fetch; -class FetchException extends \Exception +class Exception extends \Exception { /** * Constructor diff --git a/tests/ChunkTest.php b/tests/ChunkTest.php new file mode 100644 index 0000000..53ff8aa --- /dev/null +++ b/tests/ChunkTest.php @@ -0,0 +1,125 @@ +assertEquals($data, $chunk->getData()); + $this->assertIsString($chunk->getData()); + + // Test getSize method + $this->assertEquals($size, $chunk->getSize()); + $this->assertIsInt($chunk->getSize()); + $this->assertEquals(strlen($chunk->getData()), $chunk->getSize()); + + // Test getTimestamp method + $this->assertEquals($timestamp, $chunk->getTimestamp()); + $this->assertIsFloat($chunk->getTimestamp()); + + // Test getIndex method + $this->assertEquals($index, $chunk->getIndex()); + $this->assertIsInt($chunk->getIndex()); + } + + /** + * Test chunk with empty data + * @return void + */ + public function testEmptyChunk(): void + { + $data = ''; + $size = 0; + $timestamp = microtime(true); + $index = 1; + + $chunk = new Chunk($data, $size, $timestamp, $index); + + $this->assertEquals('', $chunk->getData()); + $this->assertEquals(0, $chunk->getSize()); + $this->assertEquals($timestamp, $chunk->getTimestamp()); + $this->assertEquals(1, $chunk->getIndex()); + } + + /** + * Test chunk with binary data + * @return void + */ + public function testBinaryChunk(): void + { + $data = pack('C*', 0x48, 0x65, 0x6c, 0x6c, 0x6f); // "Hello" in binary + $size = strlen($data); + $timestamp = microtime(true); + $index = 2; + + $chunk = new Chunk($data, $size, $timestamp, $index); + + $this->assertEquals($data, $chunk->getData()); + $this->assertEquals(5, $chunk->getSize()); + $this->assertEquals($timestamp, $chunk->getTimestamp()); + $this->assertEquals(2, $chunk->getIndex()); + $this->assertEquals("Hello", $chunk->getData()); + } + + /** + * Test chunk with special characters + * @return void + */ + public function testSpecialCharactersChunk(): void + { + $data = "Special chars: ñ, é, 漢字, 🌟"; + $size = strlen($data); + $timestamp = microtime(true); + $index = 3; + + $chunk = new Chunk($data, $size, $timestamp, $index); + + $this->assertEquals($data, $chunk->getData()); + $this->assertEquals($size, $chunk->getSize()); + $this->assertEquals($timestamp, $chunk->getTimestamp()); + $this->assertEquals(3, $chunk->getIndex()); + } + + /** + * Test chunk immutability + * @return void + */ + public function testChunkImmutability(): void + { + $data = "test data"; + $size = strlen($data); + $timestamp = microtime(true); + $index = 4; + + $chunk = new Chunk($data, $size, $timestamp, $index); + $originalData = $chunk->getData(); + $originalSize = $chunk->getSize(); + $originalTimestamp = $chunk->getTimestamp(); + $originalIndex = $chunk->getIndex(); + + // Try to modify the data (this should create a new string, not modify the chunk) + $modifiedData = $chunk->getData() . " modified"; + + // Verify original chunk remains unchanged + $this->assertEquals($originalData, $chunk->getData()); + $this->assertEquals($originalSize, $chunk->getSize()); + $this->assertEquals($originalTimestamp, $chunk->getTimestamp()); + $this->assertEquals($originalIndex, $chunk->getIndex()); + $this->assertNotEquals($modifiedData, $chunk->getData()); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 5b9276e..028670e 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -39,7 +39,7 @@ public function testFetch( body: $body, query: $query ); - } catch (FetchException $e) { + } catch (Exception $e) { echo $e; return; } @@ -107,7 +107,7 @@ public function testSendFile( ], query: [] ); - } catch (FetchException $e) { + } catch (Exception $e) { echo $e; return; } @@ -156,7 +156,7 @@ public function testGetFile( body: [], query: [] ); - } catch (FetchException $e) { + } catch (Exception $e) { echo $e; return; } @@ -189,7 +189,7 @@ public function testRedirect(): void body: [], query: [] ); - } catch (FetchException $e) { + } catch (Exception $e) { echo $e; return; } @@ -418,4 +418,142 @@ public function testCustomRetryStatusCodes(): void $this->assertGreaterThan($now + 3.0, microtime(true)); unlink(__DIR__ . '/state.json'); } + + /** + * Test for chunk handling + * @return void + */ + public function testChunkHandling(): void + { + $client = new Client(); + $chunks = []; + $lastChunk = null; + + $response = $client->fetch( + url: 'localhost:8000/chunked', + method: Client::METHOD_GET, + chunks: function (Chunk $chunk) use (&$chunks, &$lastChunk) { + $chunks[] = $chunk; + $lastChunk = $chunk; + } + ); + + $this->assertGreaterThan(0, count($chunks)); + $this->assertEquals(200, $response->getStatusCode()); + + // Test chunk metadata + foreach ($chunks as $index => $chunk) { + $this->assertEquals($index, $chunk->getIndex()); + $this->assertGreaterThan(0, $chunk->getSize()); + $this->assertGreaterThan(0, $chunk->getTimestamp()); + $this->assertNotEmpty($chunk->getData()); + } + + // Verify last chunk exists + $this->assertNotNull($lastChunk); + } + + /** + * Test chunk handling with JSON response + * @return void + */ + public function testChunkHandlingWithJson(): void + { + $client = new Client(); + $client->addHeader('content-type', 'application/json'); + + $chunks = []; + $response = $client->fetch( + url: 'localhost:8000/chunked-json', + method: Client::METHOD_POST, + body: ['test' => 'data'], + chunks: function (Chunk $chunk) use (&$chunks) { + $chunks[] = $chunk; + } + ); + + $this->assertGreaterThan(0, count($chunks)); + + // Test JSON handling + foreach ($chunks as $chunk) { + $data = $chunk->getData(); + $this->assertNotEmpty($data); + + // Verify each chunk is valid JSON + $decoded = json_decode($data, true); + $this->assertNotNull($decoded); + $this->assertIsArray($decoded); + $this->assertArrayHasKey('chunk', $decoded); + $this->assertArrayHasKey('data', $decoded); + } + } + + /** + * Test chunk handling with error response + * @return void + */ + public function testChunkHandlingWithError(): void + { + $client = new Client(); + $errorChunk = null; + + $response = $client->fetch( + url: 'localhost:8000/error', + method: Client::METHOD_GET, + chunks: function (Chunk $chunk) use (&$errorChunk) { + if ($errorChunk === null) { + $errorChunk = $chunk; + } + } + ); + + $this->assertNotNull($errorChunk); + if ($errorChunk !== null) { + $this->assertNotEmpty($errorChunk->getData()); + } + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * Test chunk handling with chunked error response + * @return void + */ + public function testChunkHandlingWithChunkedError(): void + { + $client = new Client(); + $client->addHeader('content-type', 'application/json'); + $chunks = []; + $errorMessages = []; + + $response = $client->fetch( + url: 'localhost:8000/chunked-error', + method: Client::METHOD_GET, + chunks: function (Chunk $chunk) use (&$chunks, &$errorMessages) { + $chunks[] = $chunk; + $data = json_decode($chunk->getData(), true); + if ($data && isset($data['error'])) { + $errorMessages[] = $data['error']; + } + } + ); + + // Verify response status code + $this->assertEquals(400, $response->getStatusCode()); + + // Verify we received chunks + $this->assertCount(3, $chunks); + + // Verify error messages were received in order + $this->assertEquals([ + 'Validation error', + 'Additional details', + 'Final error message' + ], $errorMessages); + + // Test the content of specific chunks + $this->assertArrayHasKey(0, $chunks); + $firstChunk = json_decode($chunks[0]->getData(), true); + $this->assertIsArray($firstChunk); + $this->assertEquals('username', $firstChunk['field']); + } } diff --git a/tests/router.php b/tests/router.php index 544ed22..e84f145 100644 --- a/tests/router.php +++ b/tests/router.php @@ -44,8 +44,7 @@ function setState(array $newState): void if ($curPageName == 'redirect') { header('Location: http://localhost:8000/redirectedPage'); exit; -} -if ($curPageName == 'image') { +} elseif ($curPageName == 'image') { $filename = __DIR__."/resources/logo.png"; header("Content-disposition: attachment;filename=$filename"); header("Content-type: application/octet-stream"); @@ -85,15 +84,89 @@ function setState(array $newState): void 'success' => true, 'attempts' => $state['attempts'] ]); +} elseif ($curPageName == 'chunked') { + // Set headers for chunked response + header('Content-Type: text/plain'); + header('Transfer-Encoding: chunked'); + + // Send chunks with delay + $chunks = [ + "This is the first chunk\n", + "This is the second chunk\n", + "This is the final chunk\n" + ]; + + foreach ($chunks as $chunk) { + printf("%x\r\n%s\r\n", strlen($chunk), $chunk); + flush(); + usleep(100000); // 100ms delay between chunks + } + + // Send the final empty chunk to indicate the end of the response + echo "0\r\n\r\n"; + exit; +} elseif ($curPageName == 'chunked-json') { + // Set headers for chunked JSON response + header('Content-Type: application/json'); + header('Transfer-Encoding: chunked'); + + // Send JSON chunks + $chunks = [ + json_encode(['chunk' => 1, 'data' => 'First chunk']), + json_encode(['chunk' => 2, 'data' => 'Second chunk']), + json_encode(['chunk' => 3, 'data' => 'Final chunk']) + ]; + + foreach ($chunks as $chunk) { + $chunk .= "\n"; // Add newline for JSON lines format + printf("%x\r\n%s\r\n", strlen($chunk), $chunk); + flush(); + usleep(100000); // 100ms delay between chunks + } + + // Send the final empty chunk to indicate the end of the response + echo "0\r\n\r\n"; + exit; +} elseif ($curPageName == 'chunked-error') { + // Set error status code + http_response_code(400); + + // Set headers for chunked JSON response + header('Content-Type: application/json'); + header('Transfer-Encoding: chunked'); + + // Send JSON chunks with error details + $chunks = [ + json_encode(['error' => 'Validation error', 'field' => 'username']), + json_encode(['error' => 'Additional details', 'context' => 'Form submission']), + json_encode(['error' => 'Final error message', 'code' => 'INVALID_INPUT']) + ]; + + foreach ($chunks as $chunk) { + $chunk .= "\n"; // Add newline for JSON lines format + printf("%x\r\n%s\r\n", strlen($chunk), $chunk); + flush(); + usleep(100000); // 100ms delay between chunks + } + + // Send the final empty chunk to indicate the end of the response + echo "0\r\n\r\n"; + exit; +} elseif ($curPageName == 'error') { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Not found']); + exit; } + $resp = [ - 'method' => $method, - 'url' => $url, - 'query' => $query, - 'body' => $body, - 'headers' => json_encode($headers), - 'files' => json_encode($files), - 'page' => $curPageName, + 'method' => $method, + 'url' => $url, + 'query' => $query, + 'body' => $body, + 'headers' => json_encode($headers), + 'files' => json_encode($files), + 'page' => $curPageName, ]; echo json_encode($resp);