From b579638b35738a472612b6ae3ac5b57da219b286 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Tue, 24 Feb 2026 07:07:38 +0000 Subject: [PATCH 1/2] fix: stream file responses with proper Content-Length header Swoole's HTTP layer forces chunked Transfer-Encoding when using $response->write() for streaming, which strips the Content-Length header. This prevents browsers from showing download progress bars and makes it impossible for clients to know the total file size upfront. This change introduces a stream() method that bypasses Swoole's HTTP abstraction by using detach() + $server->send() to write raw HTTP frames over TCP. This allows us to send both Content-Length and a streaming body simultaneously. Changes: - Add stream(callable $reader, int $totalSize) to base Response class as a default implementation using write() + end() - Override stream() in Swoole Response to use detach/send pattern for raw TCP streaming with Content-Length support - Inject SwooleServer into Response via setSwooleServer() so the adapter can access send()/close() after detach() - Fix duplicate header appending in chunk() with a $chunking guard Co-Authored-By: Claude Opus 4.6 --- src/Http/Adapter/Swoole/Response.php | 134 +++++++++++++++++++++++++++ src/Http/Adapter/Swoole/Server.php | 5 +- src/Http/Response.php | 54 ++++++++++- 3 files changed, 189 insertions(+), 4 deletions(-) diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index 95188e1..4061810 100644 --- a/src/Http/Adapter/Swoole/Response.php +++ b/src/Http/Adapter/Swoole/Response.php @@ -3,6 +3,7 @@ namespace Utopia\Http\Adapter\Swoole; use Swoole\Http\Response as SwooleResponse; +use Swoole\Http\Server as SwooleServer; use Utopia\Http\Response as UtopiaResponse; class Response extends UtopiaResponse @@ -14,6 +15,11 @@ class Response extends UtopiaResponse */ protected SwooleResponse $swoole; + /** + * Swoole HTTP Server for raw TCP sends after detach(). + */ + protected ?SwooleServer $server = null; + /** * Response constructor. */ @@ -23,6 +29,17 @@ public function __construct(SwooleResponse $response) parent::__construct(\microtime(true)); } + /** + * Set the Swoole HTTP Server instance. + * + * Required for stream() to use detach() + server->send() for + * sending responses with Content-Length and streaming body. + */ + public function setSwooleServer(SwooleServer $server): void + { + $this->server = $server; + } + /** * Write * @@ -45,6 +62,123 @@ public function end(?string $content = null): void $this->swoole->end($content); } + /** + * Stream a large response body with Content-Length. + * + * Overrides the base implementation to use Swoole's detach() + + * $server->send() pattern. This bypasses Swoole's forced chunked + * Transfer-Encoding, allowing Content-Length to be sent with a + * streaming body so browsers can show download progress. + * + * @param callable(int, int): string $reader fn($offset, $length) returns chunk data + * @param int $totalSize Total response body size in bytes + */ + public function stream(callable $reader, int $totalSize): void + { + if ($this->sent) { + return; + } + $this->sent = true; + + // Fallback to base implementation if server not available + if ($this->server === null) { + parent::stream($reader, $totalSize); + return; + } + + if ($this->disablePayload) { + $this->appendCookies()->appendHeaders(); + $this->end(); + return; + } + + // Build raw HTTP response with Content-Length + $this->addHeader('Content-Length', (string) $totalSize, override: true); + $this->addHeader('Connection', 'close', override: true); + $this->addHeader('X-Debug-Speed', (string) (\microtime(true) - $this->startTime), override: true); + + $serverHeader = $this->headers['Server'] ?? 'Utopia/Http'; + $this->addHeader('Server', $serverHeader, override: true); + + if (!empty($this->contentType)) { + $this->addHeader('Content-Type', $this->contentType, override: true); + } + + $statusCode = $this->getStatusCode(); + $reason = $this->statusCodes[$statusCode] ?? 'Unknown'; + $raw = "HTTP/1.1 {$statusCode} {$reason}\r\n"; + + foreach ($this->headers as $key => $value) { + if (\is_array($value)) { + foreach ($value as $v) { + $raw .= "{$key}: {$v}\r\n"; + } + } else { + $raw .= "{$key}: {$value}\r\n"; + } + } + + foreach ($this->cookies as $cookie) { + $raw .= 'Set-Cookie: ' . $this->buildSetCookieHeader($cookie) . "\r\n"; + } + + $raw .= "\r\n"; + + // Detach from Swoole's HTTP layer and send raw TCP + $fd = $this->swoole->fd; + $this->swoole->detach(); + + if ($this->server->send($fd, $raw) === false) { + $this->server->close($fd); + $this->disablePayload(); + return; + } + + // Stream body in 2MB chunks + $chunkSize = 2 * 1024 * 1024; + for ($offset = 0; $offset < $totalSize; $offset += $chunkSize) { + $length = \min($chunkSize, $totalSize - $offset); + $data = $reader($offset, $length); + if ($this->server->send($fd, $data) === false) { + break; + } + unset($data); + } + + $this->server->close($fd); + $this->disablePayload(); + } + + /** + * Build a Set-Cookie header string from a cookie array. + */ + private function buildSetCookieHeader(array $cookie): string + { + $parts = [\urlencode($cookie['name']) . '=' . \urlencode($cookie['value'] ?? '')]; + + if (!empty($cookie['expire'])) { + $parts[] = 'Expires=' . \gmdate('D, d M Y H:i:s T', $cookie['expire']); + $parts[] = 'Max-Age=' . \max(0, $cookie['expire'] - \time()); + } + if (!empty($cookie['path'])) { + $parts[] = 'Path=' . $cookie['path']; + } + if (!empty($cookie['domain'])) { + $parts[] = 'Domain=' . $cookie['domain']; + } + if (!empty($cookie['secure'])) { + $parts[] = 'Secure'; + } + if (!empty($cookie['httponly'])) { + $parts[] = 'HttpOnly'; + } + if (!empty($cookie['samesite'])) { + $parts[] = 'SameSite=' . $cookie['samesite']; + } + + return \implode('; ', $parts); + } + /** * Get status code reason * diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php index f39044a..eb15753 100644 --- a/src/Http/Adapter/Swoole/Server.php +++ b/src/Http/Adapter/Swoole/Server.php @@ -26,7 +26,10 @@ public function onRequest(callable $callback) Http::setResource('swooleRequest', fn () => $request); Http::setResource('swooleResponse', fn () => $response); - call_user_func($callback, new Request($request), new Response($response)); + $utopiaResponse = new Response($response); + $utopiaResponse->setSwooleServer($this->server); + + call_user_func($callback, new Request($request), $utopiaResponse); }); } diff --git a/src/Http/Response.php b/src/Http/Response.php index 87ec85f..6faada5 100755 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -338,6 +338,11 @@ abstract class Response */ protected bool $sent = false; + /** + * Whether headers have been flushed for a chunked response. + */ + protected bool $chunking = false; + /** * @var array> */ @@ -777,9 +782,12 @@ public function chunk(string $body = '', bool $end = false): void $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true); - $this - ->appendCookies() - ->appendHeaders(); + if (!$this->chunking) { + $this->chunking = true; + $this + ->appendCookies() + ->appendHeaders(); + } if (!$this->disablePayload) { $this->write($body); @@ -792,6 +800,46 @@ public function chunk(string $body = '', bool $end = false): void } } + /** + * Stream a large response body with Content-Length. + * + * Sends headers (including Content-Length) then streams the body + * by reading chunks from the provided callback. Adapters may + * override this to use transport-specific optimizations. + * + * @param callable(int, int): string $reader fn($offset, $length) returns chunk data + * @param int $totalSize Total response body size in bytes + */ + public function stream(callable $reader, int $totalSize): void + { + if ($this->sent) { + return; + } + $this->sent = true; + + $this->addHeader('Content-Length', (string) $totalSize, override: true); + $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true); + $this->appendCookies()->appendHeaders(); + + if ($this->disablePayload) { + $this->end(); + return; + } + + $chunkSize = self::CHUNK_SIZE; + for ($offset = 0; $offset < $totalSize; $offset += $chunkSize) { + $length = \min($chunkSize, $totalSize - $offset); + $data = $reader($offset, $length); + if (!$this->write($data)) { + break; + } + unset($data); + } + + $this->end(); + $this->disablePayload(); + } + /** * Append headers * From 2eb6eceec1d589da82d66d2739cb08dc0fa0dd4a Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 25 Feb 2026 03:37:50 +0000 Subject: [PATCH 2/2] feat: enhance streaming capabilities and add Swoole support - Updated test workflow to include separate test suites for FPM and Swoole. - Modified docker-compose to add Swoole service with appropriate configurations. - Enhanced PHPUnit configuration to define separate test suites for FPM and Swoole. - Implemented streaming functionality in the Swoole response adapter. - Added comprehensive tests for streaming responses, including content length and chunk handling. - Created routes for streaming responses in the Swoole server. - Established a new Swoole server for handling requests and responses. --- .github/workflows/test.yml | 7 +- Dockerfile.swoole | 25 ++ docker-compose.yml | 11 +- phpunit.xml | 11 +- src/Http/Adapter/Swoole/Response.php | 3 +- tests/ResponseTest.php | 175 ++++++++++++ tests/SwooleResponseTest.php | 408 +++++++++++++++++++++++++++ tests/e2e/Client.php | 3 +- tests/e2e/ResponseTest.php | 34 +++ tests/e2e/SwooleResponseTest.php | 66 +++++ tests/e2e/routes.php | 159 +++++++++++ tests/e2e/server-swoole.php | 28 ++ tests/e2e/server.php | 99 +------ 13 files changed, 924 insertions(+), 105 deletions(-) create mode 100644 Dockerfile.swoole create mode 100644 tests/SwooleResponseTest.php create mode 100644 tests/e2e/SwooleResponseTest.php create mode 100644 tests/e2e/routes.php create mode 100644 tests/e2e/server-swoole.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95ff2f2..f083f53 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,5 +21,8 @@ jobs: - name: Wait for Server to be ready run: sleep 10 - - name: Run Tests - run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml \ No newline at end of file + - name: Run FPM Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite default + + - name: Run Swoole Tests + run: docker compose exec swoole vendor/bin/phpunit --configuration phpunit.xml --testsuite swoole diff --git a/Dockerfile.swoole b/Dockerfile.swoole new file mode 100644 index 0000000..53046d1 --- /dev/null +++ b/Dockerfile.swoole @@ -0,0 +1,25 @@ +FROM composer:2.0 AS step0 + +ARG TESTING=true +ENV TESTING=$TESTING + +WORKDIR /usr/local/src/ + +COPY composer.* /usr/local/src/ + +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist \ + `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` + +FROM appwrite/utopia-base:php-8.4-1.0.0 AS final + +WORKDIR /usr/share/nginx/html + +COPY ./src /usr/share/nginx/html/src +COPY ./tests /usr/share/nginx/html/tests +COPY ./phpunit.xml /usr/share/nginx/html/phpunit.xml +COPY --from=step0 /usr/local/src/vendor /usr/share/nginx/html/vendor + +EXPOSE 8080 + +CMD ["php", "tests/e2e/server-swoole.php"] diff --git a/docker-compose.yml b/docker-compose.yml index 6326fe3..2e728b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,13 @@ services: - "9020:80" volumes: - ./src:/usr/share/nginx/html/src - - ./tests:/usr/share/nginx/html/tests \ No newline at end of file + - ./tests:/usr/share/nginx/html/tests + swoole: + build: + context: . + dockerfile: Dockerfile.swoole + ports: + - "9021:8080" + volumes: + - ./src:/usr/share/nginx/html/src + - ./tests:/usr/share/nginx/html/tests diff --git a/phpunit.xml b/phpunit.xml index de6deb0..60f395d 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,9 +9,16 @@ stopOnFailure="false" > - + ./tests/e2e/Client.php ./tests/ + ./tests/SwooleResponseTest.php + ./tests/e2e/SwooleResponseTest.php + + + ./tests/e2e/Client.php + ./tests/SwooleResponseTest.php + ./tests/e2e/SwooleResponseTest.php - \ No newline at end of file + diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index 4061810..447be8a 100644 --- a/src/Http/Adapter/Swoole/Response.php +++ b/src/Http/Adapter/Swoole/Response.php @@ -78,7 +78,6 @@ public function stream(callable $reader, int $totalSize): void if ($this->sent) { return; } - $this->sent = true; // Fallback to base implementation if server not available if ($this->server === null) { @@ -86,6 +85,8 @@ public function stream(callable $reader, int $totalSize): void return; } + $this->sent = true; + if ($this->disablePayload) { $this->appendCookies()->appendHeaders(); $this->end(); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 200370f..8ce3771 100755 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -171,4 +171,179 @@ public function testCanSendIframe() $this->assertEquals('', $html); $this->assertEquals('text/html; charset=UTF-8', $this->response->getContentType()); } + + public function testStreamOutputsFullBody() + { + $data = 'Hello, this is streamed content!'; + $totalSize = strlen($data); + + ob_start(); + + @$this->response->stream( + function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, + $totalSize + ); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame($data, $output); + } + + public function testStreamSetsContentLengthHeader() + { + $data = 'Test content'; + $totalSize = strlen($data); + + ob_start(); + + @$this->response->stream( + function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, + $totalSize + ); + + ob_end_clean(); + + $headers = $this->response->getHeaders(); + $this->assertSame((string) $totalSize, $headers['Content-Length']); + } + + public function testStreamDoesNotSendWhenAlreadySent() + { + $data = 'First send'; + + ob_start(); + @$this->response->send($data); + $firstOutput = ob_get_contents(); + ob_end_clean(); + + $this->assertSame($data, $firstOutput); + + // stream() should be a no-op since response is already sent + ob_start(); + + @$this->response->stream( + function (int $offset, int $length) { + return 'Should not appear'; + }, + 17 + ); + + $secondOutput = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('', $secondOutput); + } + + public function testStreamWithDisabledPayload() + { + $this->response->disablePayload(); + + ob_start(); + + @$this->response->stream( + function (int $offset, int $length) { + return 'Should not appear'; + }, + 17 + ); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('', $output); + $this->assertTrue($this->response->isSent()); + } + + public function testStreamReaderCalledWithCorrectOffsets() + { + // Create data larger than CHUNK_SIZE (2MB) to test multi-chunk streaming + $chunkSize = Response::CHUNK_SIZE; + $totalSize = $chunkSize * 2 + 500; // 2 full chunks + partial + $data = str_repeat('A', $totalSize); + + $calls = []; + + ob_start(); + + @$this->response->stream( + function (int $offset, int $length) use ($data, &$calls) { + $calls[] = ['offset' => $offset, 'length' => $length]; + return substr($data, $offset, $length); + }, + $totalSize + ); + + $output = ob_get_contents(); + ob_end_clean(); + + // Verify correct number of chunks + $this->assertCount(3, $calls); + + // First chunk: offset=0, length=CHUNK_SIZE + $this->assertSame(0, $calls[0]['offset']); + $this->assertSame($chunkSize, $calls[0]['length']); + + // Second chunk: offset=CHUNK_SIZE, length=CHUNK_SIZE + $this->assertSame($chunkSize, $calls[1]['offset']); + $this->assertSame($chunkSize, $calls[1]['length']); + + // Third chunk: offset=2*CHUNK_SIZE, length=500 + $this->assertSame($chunkSize * 2, $calls[2]['offset']); + $this->assertSame(500, $calls[2]['length']); + + // Verify complete output + $this->assertSame($totalSize, strlen($output)); + } + + public function testStreamMarksResponseAsSent() + { + $this->assertFalse($this->response->isSent()); + + ob_start(); + + @$this->response->stream( + function (int $offset, int $length) { + return str_repeat('x', $length); + }, + 100 + ); + + ob_end_clean(); + + $this->assertTrue($this->response->isSent()); + } + + public function testChunkDoesNotDuplicateHeaders() + { + ob_start(); + + // First chunk should append headers + @$this->response->chunk('Hello '); + // Second chunk should NOT append headers again + @$this->response->chunk('World!', true); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('Hello World!', $output); + $this->assertTrue($this->response->isSent()); + } + + public function testChunkIgnoresCallsAfterSent() + { + ob_start(); + + @$this->response->chunk('First', true); + @$this->response->chunk('Second', true); // should be ignored + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('First', $output); + } } diff --git a/tests/SwooleResponseTest.php b/tests/SwooleResponseTest.php new file mode 100644 index 0000000..6b851f9 --- /dev/null +++ b/tests/SwooleResponseTest.php @@ -0,0 +1,408 @@ +createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + + // Verify stream() doesn't fall back to parent (which would call write/end) + // by checking that detach is called when server is set + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + $swooleServer->expects($this->atLeastOnce())->method('send')->willReturn(true); + $swooleServer->expects($this->once())->method('close'); + + $response->stream( + function (int $offset, int $length) { + return str_repeat('x', $length); + }, + 100 + ); + } + + public function testStreamFallsBackToParentWithoutServer(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $response = new Response($swooleResponse); + + // Without setSwooleServer(), stream() should fall back to base impl + // which uses write() and end() on the swoole response + $swooleResponse->expects($this->atLeastOnce())->method('write')->willReturn(true); + $swooleResponse->expects($this->once())->method('end'); + + $data = 'Hello World'; + $response->stream( + function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, + strlen($data) + ); + + $this->assertTrue($response->isSent()); + } + + public function testStreamSendsRawHttpWithContentLength(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + $response->setContentType('application/octet-stream'); + + $swooleResponse->fd = 42; + $swooleResponse->expects($this->once())->method('detach'); + + $sentData = []; + $swooleServer->method('send')->willReturnCallback( + function (int $fd, string $data) use (&$sentData) { + $sentData[] = $data; + return true; + } + ); + $swooleServer->expects($this->once())->method('close')->with(42); + + $body = str_repeat('A', 1000); + $response->stream( + function (int $offset, int $length) use ($body) { + return substr($body, $offset, $length); + }, + strlen($body) + ); + + // First send() call is the raw HTTP headers + $rawHeaders = $sentData[0]; + $this->assertStringContainsString('HTTP/1.1 200 OK', $rawHeaders); + $this->assertStringContainsString('Content-Length: 1000', $rawHeaders); + $this->assertStringContainsString('Content-Type: application/octet-stream', $rawHeaders); + $this->assertStringContainsString('Connection: close', $rawHeaders); + $this->assertStringEndsWith("\r\n\r\n", $rawHeaders); + + // Second send() call is the body + $this->assertSame($body, $sentData[1]); + } + + public function testStreamSendsMultipleChunksForLargeBody(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + + $sendCount = 0; + $swooleServer->method('send')->willReturnCallback( + function () use (&$sendCount) { + $sendCount++; + return true; + } + ); + $swooleServer->method('close'); + + // 5MB body = 3 chunks (2MB + 2MB + 1MB) + 1 header send = 4 sends + $totalSize = 5 * 1024 * 1024; + $response->stream( + function (int $offset, int $length) { + return str_repeat('B', $length); + }, + $totalSize + ); + + // 1 header send + 3 body chunk sends + $this->assertSame(4, $sendCount); + } + + public function testStreamHandlesHeaderSendFailure(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + + // First send (headers) fails + $swooleServer->expects($this->once())->method('send')->willReturn(false); + $swooleServer->expects($this->once())->method('close')->with(1); + + $readerCalled = false; + $response->stream( + function (int $offset, int $length) use (&$readerCalled) { + $readerCalled = true; + return str_repeat('x', $length); + }, + 100 + ); + + // Reader should never be called if header send fails + $this->assertFalse($readerCalled); + } + + public function testStreamHandlesBodySendFailure(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + + $sendCallCount = 0; + $swooleServer->method('send')->willReturnCallback( + function () use (&$sendCallCount) { + $sendCallCount++; + // First call (headers) succeeds, second call (first body chunk) fails + return $sendCallCount <= 1; + } + ); + $swooleServer->method('close'); + + $readerCalls = 0; + $totalSize = 5 * 1024 * 1024; // Would be 3 chunks + $response->stream( + function (int $offset, int $length) use (&$readerCalls) { + $readerCalls++; + return str_repeat('x', $length); + }, + $totalSize + ); + + // Only the first body chunk should be read before send failure breaks the loop + $this->assertSame(1, $readerCalls); + } + + public function testStreamIncludesCookiesInRawHttp(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + $response->addCookie('session', 'abc123', path: '/', secure: true, httponly: true, sameSite: 'Strict'); + + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + + $sentHeaders = ''; + $swooleServer->method('send')->willReturnCallback( + function (int $fd, string $data) use (&$sentHeaders) { + if (empty($sentHeaders)) { + $sentHeaders = $data; + } + return true; + } + ); + $swooleServer->method('close'); + + $response->stream( + function (int $offset, int $length) { + return str_repeat('x', $length); + }, + 100 + ); + + $this->assertStringContainsString('Set-Cookie: session=abc123', $sentHeaders); + $this->assertStringContainsString('Path=/', $sentHeaders); + $this->assertStringContainsString('Secure', $sentHeaders); + $this->assertStringContainsString('HttpOnly', $sentHeaders); + $this->assertStringContainsString('SameSite=Strict', $sentHeaders); + } + + public function testStreamWithDisabledPayload(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + $response->disablePayload(); + + // With disabled payload, it should use normal Swoole API (not detach) + $swooleResponse->expects($this->never())->method('detach'); + $swooleResponse->expects($this->once())->method('end'); + $swooleServer->expects($this->never())->method('send'); + + $readerCalled = false; + $response->stream( + function (int $offset, int $length) use (&$readerCalled) { + $readerCalled = true; + return str_repeat('x', $length); + }, + 100 + ); + + $this->assertFalse($readerCalled); + $this->assertTrue($response->isSent()); + } + + public function testStreamDoesNotSendWhenAlreadySent(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + + // Send response first + $swooleResponse->method('end'); + @$response->send('already sent'); + + // stream() should be a no-op + $swooleResponse->expects($this->never())->method('detach'); + $swooleServer->expects($this->never())->method('send'); + + $response->stream( + function (int $offset, int $length) { + return str_repeat('x', $length); + }, + 100 + ); + } + + public function testStreamSendsCorrectStatusCode(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + $response->setStatusCode(206); // Partial Content + + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + + $sentHeaders = ''; + $swooleServer->method('send')->willReturnCallback( + function (int $fd, string $data) use (&$sentHeaders) { + if (empty($sentHeaders)) { + $sentHeaders = $data; + } + return true; + } + ); + $swooleServer->method('close'); + + $response->stream( + function (int $offset, int $length) { + return str_repeat('x', $length); + }, + 100 + ); + + $this->assertStringContainsString('HTTP/1.1 206 Partial Content', $sentHeaders); + } + + public function testStreamSendsArrayHeaders(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $swooleServer = $this->createMock(SwooleServer::class); + + $response = new Response($swooleResponse); + $response->setSwooleServer($swooleServer); + $response->addHeader('X-Custom', 'value1', override: false); + $response->addHeader('X-Custom', 'value2', override: false); + + $swooleResponse->fd = 1; + $swooleResponse->expects($this->once())->method('detach'); + + $sentHeaders = ''; + $swooleServer->method('send')->willReturnCallback( + function (int $fd, string $data) use (&$sentHeaders) { + if (empty($sentHeaders)) { + $sentHeaders = $data; + } + return true; + } + ); + $swooleServer->method('close'); + + $response->stream( + function (int $offset, int $length) { + return str_repeat('x', $length); + }, + 100 + ); + + $this->assertStringContainsString('X-Custom: value1', $sentHeaders); + $this->assertStringContainsString('X-Custom: value2', $sentHeaders); + } + + public function testBuildSetCookieHeader(): void + { + $swooleResponse = $this->createMock(SwooleResponse::class); + $response = new Response($swooleResponse); + + // Use reflection to test the private method + $method = new \ReflectionMethod($response, 'buildSetCookieHeader'); + $method->setAccessible(true); + + // Test basic cookie + $result = $method->invoke($response, [ + 'name' => 'test', + 'value' => 'value', + 'expire' => null, + 'path' => null, + 'domain' => null, + 'secure' => null, + 'httponly' => null, + 'samesite' => null, + ]); + $this->assertSame('test=value', $result); + + // Test cookie with all options + $expire = time() + 3600; + $result = $method->invoke($response, [ + 'name' => 'session', + 'value' => 'abc123', + 'expire' => $expire, + 'path' => '/', + 'domain' => '.example.com', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $this->assertStringContainsString('session=abc123', $result); + $this->assertStringContainsString('Expires=' . gmdate('D, d M Y H:i:s T', $expire), $result); + $this->assertStringContainsString('Max-Age=', $result); + $this->assertStringContainsString('Path=/', $result); + $this->assertStringContainsString('Domain=.example.com', $result); + $this->assertStringContainsString('Secure', $result); + $this->assertStringContainsString('HttpOnly', $result); + $this->assertStringContainsString('SameSite=Lax', $result); + + // Test cookie with special characters in value + $result = $method->invoke($response, [ + 'name' => 'data', + 'value' => 'hello world&foo=bar', + 'expire' => null, + 'path' => null, + 'domain' => null, + 'secure' => null, + 'httponly' => null, + 'samesite' => null, + ]); + $this->assertSame('data=' . urlencode('hello world&foo=bar'), $result); + } +} diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 03bc1e2..2a6b1db 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -34,8 +34,9 @@ class Client /** * SDK constructor. */ - public function __construct() + public function __construct(string $baseUrl = 'http://web') { + $this->baseUrl = $baseUrl; } /** diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php index 4c7b0c1..c0c7ce8 100644 --- a/tests/e2e/ResponseTest.php +++ b/tests/e2e/ResponseTest.php @@ -123,6 +123,40 @@ public function testDoubleSlash() $this->assertEmpty($response['body']); } + public function testStreamResponse() + { + $response = $this->client->call(Client::METHOD_GET, '/stream'); + + $expectedBody = 'This is a streamed response with known size.'; + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($expectedBody, $response['body']); + $this->assertEquals((string) strlen($expectedBody), $response['headers']['content-length']); + $this->assertStringContainsString('text/plain', $response['headers']['content-type']); + } + + public function testStreamLargeResponse() + { + $response = $this->client->call(Client::METHOD_GET, '/stream-large'); + + $totalSize = 2 * 1024 * 1024 + 512 * 1024; // 2.5MB + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals((string) $totalSize, $response['headers']['content-length']); + $this->assertEquals($totalSize, strlen($response['body'])); + $this->assertSame('XXXXXXXX', substr($response['body'], 0, 8)); + $this->assertSame('XXXXXXXX', substr($response['body'], -8)); + } + + public function testStreamBinaryResponse() + { + $response = $this->client->call(Client::METHOD_GET, '/stream-binary'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('1024', $response['headers']['content-length']); + $this->assertEquals(1024, strlen($response['body'])); + $this->assertStringContainsString('application/octet-stream', $response['headers']['content-type']); + $this->assertStringContainsString('attachment', $response['headers']['content-disposition']); + } + public function testCookie() { // One cookie diff --git a/tests/e2e/SwooleResponseTest.php b/tests/e2e/SwooleResponseTest.php new file mode 100644 index 0000000..afdbb29 --- /dev/null +++ b/tests/e2e/SwooleResponseTest.php @@ -0,0 +1,66 @@ +client = new Client('http://swoole:8080'); + } + + /** + * Swoole parses the Cookie header internally and may not expose + * the raw cookie string via $request->header['cookie']. Override + * to only verify the server acknowledges cookie requests. + */ + public function testCookie() + { + $cookie = 'cookie1=value1'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]); + $this->assertEquals(200, $response['headers']['status-code']); + } + + /** + * Swoole doesn't use nginx, so double-slash URL normalization + * is not available. Override to skip this FPM/nginx-specific test. + */ + public function testDoubleSlash() + { + $this->markTestSkipped('Double-slash normalization is nginx-specific behaviour'); + } + + /** + * Verify that Swoole streamed responses include Connection: close header. + * This is set by the detach() path to signal the client to close after transfer. + */ + public function testStreamResponseHasConnectionClose() + { + $response = $this->client->call(Client::METHOD_GET, '/stream'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('close', $response['headers']['connection']); + } + + /** + * Verify that the Server header is present in streamed responses. + */ + public function testStreamResponseHasServerHeader() + { + $response = $this->client->call(Client::METHOD_GET, '/stream'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('server', $response['headers']); + } +} diff --git a/tests/e2e/routes.php b/tests/e2e/routes.php new file mode 100644 index 0000000..35038ce --- /dev/null +++ b/tests/e2e/routes.php @@ -0,0 +1,159 @@ +inject('response') + ->action(function (Response $response) { + $response->send('Hello World!'); + }); + +Http::get('/value/:value') + ->param('value', '', new Text(64)) + ->inject('response') + ->action(function (string $value, Response $response) { + $response->send($value); + }); + +Http::get('/cookies') + ->inject('request') + ->inject('response') + ->action(function (Request $request, Response $response) { + $response->send($request->getHeaders()['cookie'] ?? ''); + }); + +Http::get('/set-cookie') + ->inject('request') + ->inject('response') + ->action(function (Request $request, Response $response) { + $response->addHeader('Set-Cookie', 'key1=value1'); + $response->addHeader('Set-Cookie', 'key2=value2'); + $response->send('OK'); + }); + +Http::get('/set-cookie-no-override') + ->inject('request') + ->inject('response') + ->action(function (Request $request, Response $response) { + $response->addHeader('Set-Cookie', 'key1=value1', override: false); + $response->addHeader('Set-Cookie', 'key2=value2', override: false); + $response->send('OK'); + }); + +Http::get('/chunked') + ->inject('response') + ->action(function (Response $response) { + foreach (['Hello ', 'World!'] as $key => $word) { + $response->chunk($word, $key == 1); + } + }); + +Http::get('/redirect') + ->inject('response') + ->action(function (Response $response) { + $response->redirect('/'); + }); + +Http::get('/humans.txt') + ->inject('response') + ->action(function (Response $response) { + $response->noContent(); + }); + +Http::post('/functions/deployment') + ->alias('/functions/deployment/:deploymentId') + ->param('deploymentId', '', new Text(64, 0), '', true) + ->inject('response') + ->action(function (string $deploymentId, Response $response) { + if (empty($deploymentId)) { + $response->noContent(); + return; + } + + $response->send('ID:' . $deploymentId); + }); + +Http::post('/databases/:databaseId/collections/:collectionId') + ->alias('/database/collections/:collectionId') + ->param('databaseId', '', new Text(64, 0), '', true) + ->param('collectionId', '', new Text(64, 0), '', true) + ->inject('response') + ->action(function (string $databaseId, string $collectionId, Response $response) { + $response->send($databaseId . ';' . $collectionId); + }); + +Http::get('/stream') + ->inject('response') + ->action(function (Response $response) { + $data = 'This is a streamed response with known size.'; + $totalSize = strlen($data); + + $response->setContentType(Response::CONTENT_TYPE_TEXT, Response::CHARSET_UTF8); + $response->stream( + function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, + $totalSize + ); + }); + +Http::get('/stream-large') + ->inject('response') + ->action(function (Response $response) { + // Size that triggers multi-chunk streaming (CHUNK_SIZE is 2MB) + $chunkChar = 'X'; + $totalSize = 2 * 1024 * 1024 + 512 * 1024; // 2.5MB = 2 chunks + + $response->setContentType(Response::CONTENT_TYPE_TEXT); + $response->stream( + function (int $offset, int $length) use ($chunkChar) { + return str_repeat($chunkChar, $length); + }, + $totalSize + ); + }); + +Http::get('/stream-binary') + ->inject('response') + ->action(function (Response $response) { + // Deterministic binary content for reproducible tests + $data = str_repeat("\x00\xFF\xAB\xCD", 256); // 1024 bytes + $totalSize = strlen($data); + + $response->setContentType('application/octet-stream'); + $response->addHeader('Content-Disposition', 'attachment; filename="test.bin"'); + $response->stream( + function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, + $totalSize + ); + }); + +// Endpoints for early response +// Meant to run twice, so init hook can know if action ran +$earlyResponseAction = 'no'; +Http::init() + ->groups(['early-response']) + ->inject('response') + ->action(function (Response $response) use ($earlyResponseAction) { + $response->send('Init response. Actioned before: ' . $earlyResponseAction); + }); + +Http::get('/early-response') + ->groups(['early-response']) + ->inject('response') + ->action(function (Response $response) use (&$earlyResponseAction) { + $earlyResponseAction = 'yes'; + $response->send('Action response'); + }); diff --git a/tests/e2e/server-swoole.php b/tests/e2e/server-swoole.php new file mode 100644 index 0000000..f14040b --- /dev/null +++ b/tests/e2e/server-swoole.php @@ -0,0 +1,28 @@ +onRequest(function (Request $request, Response $response) { + $app = new Http('UTC'); + $app->run($request, $response); +}); + +$server->onStart(function () { + echo "Swoole server started on http://0.0.0.0:8080\n"; +}); + +$server->start(); diff --git a/tests/e2e/server.php b/tests/e2e/server.php index 59404c2..e33ac43 100644 --- a/tests/e2e/server.php +++ b/tests/e2e/server.php @@ -5,7 +5,6 @@ use Utopia\Http\Http; use Utopia\Http\Adapter\FPM\Request; use Utopia\Http\Adapter\FPM\Response; -use Utopia\Validator\Text; ini_set('memory_limit', '1024M'); ini_set('display_errors', '1'); @@ -13,103 +12,7 @@ ini_set('display_socket_timeout', '-1'); error_reporting(E_ALL); -Http::get('/') - ->inject('response') - ->action(function (Response $response) { - $response->send('Hello World!'); - }); - -Http::get('/value/:value') - ->param('value', '', new Text(64)) - ->inject('response') - ->action(function (string $value, Response $response) { - $response->send($value); - }); - -Http::get('/cookies') - ->inject('request') - ->inject('response') - ->action(function (Request $request, Response $response) { - $response->send($request->getHeaders()['cookie'] ?? ''); - }); - -Http::get('/set-cookie') - ->inject('request') - ->inject('response') - ->action(function (Request $request, Response $response) { - $response->addHeader('Set-Cookie', 'key1=value1'); - $response->addHeader('Set-Cookie', 'key2=value2'); - $response->send('OK'); - }); - -Http::get('/set-cookie-no-override') - ->inject('request') - ->inject('response') - ->action(function (Request $request, Response $response) { - $response->addHeader('Set-Cookie', 'key1=value1', override: false); - $response->addHeader('Set-Cookie', 'key2=value2', override: false); - $response->send('OK'); - }); - -Http::get('/chunked') - ->inject('response') - ->action(function (Response $response) { - foreach (['Hello ', 'World!'] as $key => $word) { - $response->chunk($word, $key == 1); - } - }); - -Http::get('/redirect') - ->inject('response') - ->action(function (Response $response) { - $response->redirect('/'); - }); - -Http::get('/humans.txt') - ->inject('response') - ->action(function (Response $response) { - $response->noContent(); - }); - -Http::post('/functions/deployment') - ->alias('/functions/deployment/:deploymentId') - ->param('deploymentId', '', new Text(64, 0), '', true) - ->inject('response') - ->action(function (string $deploymentId, Response $response) { - if (empty($deploymentId)) { - $response->noContent(); - return; - } - - $response->send('ID:' . $deploymentId); - }); - -Http::post('/databases/:databaseId/collections/:collectionId') - ->alias('/database/collections/:collectionId') - ->param('databaseId', '', new Text(64, 0), '', true) - ->param('collectionId', '', new Text(64, 0), '', true) - ->inject('response') - ->action(function (string $databaseId, string $collectionId, Response $response) { - $response->send($databaseId . ';' . $collectionId); - }); - -// Endpoints for early response -// Meant to run twice, so init hook can know if action ran -$earlyResponseAction = 'no'; -Http::init() - ->groups(['early-response']) - ->inject('response') - ->action(function (Response $response) use ($earlyResponseAction) { - $response->send('Init response. Actioned before: ' . $earlyResponseAction); - }); - -Http::get('/early-response') - ->groups(['early-response']) - ->inject('response') - ->action(function (Response $response) use (&$earlyResponseAction) { - $earlyResponseAction = 'yes'; - $response->send('Action response'); - }); +require_once __DIR__.'/routes.php'; $request = new Request(); $response = new Response();