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 95188e1..447be8a 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,124 @@ 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;
+ }
+
+ // Fallback to base implementation if server not available
+ if ($this->server === null) {
+ parent::stream($reader, $totalSize);
+ return;
+ }
+
+ $this->sent = true;
+
+ 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
*
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();