From 71a5faffc1d117fa566825cb3b0dda2f7c2ce129 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Wed, 11 Mar 2026 08:47:30 +0000 Subject: [PATCH 1/7] feat: Implement streaming response functionality with callable and generator support --- src/Http/Adapter/Swoole/Response.php | 140 +++++++++++++++++++++++ src/Http/Adapter/Swoole/Server.php | 4 +- src/Http/Response.php | 51 +++++++++ tests/ResponseTest.php | 164 +++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 1 deletion(-) diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index 95188e1..b489b2a 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,13 @@ class Response extends UtopiaResponse */ protected SwooleResponse $swoole; + /** + * Swoole Server Object (needed for raw TCP streaming with Content-Length) + * + * @var SwooleServer|null + */ + protected ?SwooleServer $server = null; + /** * Response constructor. */ @@ -23,6 +31,19 @@ public function __construct(SwooleResponse $response) parent::__construct(\microtime(true)); } + /** + * Set the Swoole server instance for raw TCP streaming. + * + * @param SwooleServer $server + * @return static + */ + public function setServer(SwooleServer $server): static + { + $this->server = $server; + + return $this; + } + /** * Write * @@ -45,6 +66,125 @@ public function end(?string $content = null): void $this->swoole->end($content); } + /** + * Stream response + * + * Uses detach() + $server->send() for raw TCP streaming that preserves + * Content-Length (so browsers show download progress). Falls back to + * the base class implementation if no server instance is available. + * + * @param callable|\Generator $source Either a callable($offset, $length) or a Generator yielding string chunks + * @param int $totalSize Total size of the content in bytes + * @return void + */ + public function stream(callable|\Generator $source, int $totalSize): void + { + if ($this->sent) { + return; + } + + $this->sent = true; + + if ($this->server === null) { + $this->sent = false; + parent::stream($source, $totalSize); + + return; + } + + if ($this->disablePayload) { + $this->appendCookies(); + $this->appendHeaders(); + $this->swoole->end(); + $this->disablePayload(); + + return; + } + + // Build raw HTTP response headers for direct TCP send + $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); + + if (!empty($this->contentType)) { + $this->addHeader('Content-Type', $this->contentType, override: true); + } + + $statusReason = $this->getStatusCodeReason($this->statusCode); + $rawHeaders = "HTTP/1.1 {$this->statusCode} {$statusReason}\r\n"; + + foreach ($this->headers as $key => $value) { + if (\is_array($value)) { + foreach ($value as $v) { + $rawHeaders .= "{$key}: {$v}\r\n"; + } + } else { + $rawHeaders .= "{$key}: {$value}\r\n"; + } + } + + foreach ($this->cookies as $cookie) { + $cookieStr = \urlencode($cookie['name']) . '=' . \urlencode($cookie['value'] ?? ''); + if (!empty($cookie['expire'])) { + $cookieStr .= '; Expires=' . \gmdate('D, d M Y H:i:s T', $cookie['expire']); + } + if (!empty($cookie['path'])) { + $cookieStr .= '; Path=' . $cookie['path']; + } + if (!empty($cookie['domain'])) { + $cookieStr .= '; Domain=' . $cookie['domain']; + } + if (!empty($cookie['secure'])) { + $cookieStr .= '; Secure'; + } + if (!empty($cookie['httponly'])) { + $cookieStr .= '; HttpOnly'; + } + if (!empty($cookie['samesite'])) { + $cookieStr .= '; SameSite=' . $cookie['samesite']; + } + $rawHeaders .= "Set-Cookie: {$cookieStr}\r\n"; + } + + $rawHeaders .= "\r\n"; + + // Detach from Swoole HTTP layer and send raw TCP + $fd = $this->swoole->fd; + $this->swoole->detach(); + + if ($this->server->send($fd, $rawHeaders) === false) { + $this->disablePayload(); + + return; + } + + // Stream body chunks + if ($source instanceof \Generator) { + foreach ($source as $chunk) { + if (!empty($chunk)) { + $this->size += strlen($chunk); + if ($this->server->send($fd, $chunk) === false) { + break; + } + } + } + } else { + $length = self::CHUNK_SIZE; + for ($offset = 0; $offset < $totalSize; $offset += $length) { + $chunk = $source($offset, min($length, $totalSize - $offset)); + if (!empty($chunk)) { + $this->size += strlen($chunk); + if ($this->server->send($fd, $chunk) === false) { + break; + } + } + } + } + + $this->server->close($fd); + $this->disablePayload(); + } + /** * Get status code reason * diff --git a/src/Http/Adapter/Swoole/Server.php b/src/Http/Adapter/Swoole/Server.php index f39044a..f3b5110 100644 --- a/src/Http/Adapter/Swoole/Server.php +++ b/src/Http/Adapter/Swoole/Server.php @@ -26,7 +26,9 @@ 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->setServer($this->server); + call_user_func($callback, new Request($request), $utopiaResponse); }); } diff --git a/src/Http/Response.php b/src/Http/Response.php index 87ec85f..bfc5e38 100755 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -991,6 +991,57 @@ public function iframe(string $callback, array $data): void ->send(''); } + /** + * Stream response + * + * Stream file content to the client using either a callable (pull-based, offset/length) + * or a Generator/iterable (push-based, yielding chunks from a streaming source). + * + * @param callable|\Generator $source Either a callable($offset, $length) or a Generator yielding string chunks + * @param int $totalSize Total size of the content in bytes + * @return void + */ + public function stream(callable|\Generator $source, int $totalSize): void + { + if ($this->sent) { + return; + } + + $this->addHeader('Content-Length', (string) $totalSize, override: true); + $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true); + + $this->appendCookies(); + $this->appendHeaders(); + + if ($this->disablePayload) { + $this->end(); + $this->sent = true; + return; + } + + if ($source instanceof \Generator) { + foreach ($source as $chunk) { + if (!empty($chunk)) { + $this->size += strlen($chunk); + $this->write($chunk); + } + } + } else { + $length = self::CHUNK_SIZE; + for ($offset = 0; $offset < $totalSize; $offset += $length) { + $chunk = $source($offset, $length); + if (!empty($chunk)) { + $this->size += strlen($chunk); + $this->write($chunk); + } + } + } + + $this->end(); + $this->sent = true; + $this->disablePayload(); + } + /** * No Content * diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 200370f..56dee7e 100755 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -171,4 +171,168 @@ public function testCanSendIframe() $this->assertEquals('', $html); $this->assertEquals('text/html; charset=UTF-8', $this->response->getContentType()); } + + public function testStreamWithCallable() + { + $data = str_repeat('A', 100); + + ob_start(); + + @$this->response->stream(function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, strlen($data)); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals($data, $output); + $this->assertTrue($this->response->isSent()); + $this->assertEquals(strlen($data), $this->response->getSize()); + } + + public function testStreamWithGenerator() + { + $chunks = ['Hello ', 'World', '!']; + $expected = implode('', $chunks); + + $generator = (function () use ($chunks) { + foreach ($chunks as $chunk) { + yield $chunk; + } + })(); + + ob_start(); + + @$this->response->stream($generator, strlen($expected)); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals($expected, $output); + $this->assertTrue($this->response->isSent()); + $this->assertEquals(strlen($expected), $this->response->getSize()); + } + + public function testStreamWithGeneratorLargeData() + { + $chunkSize = 1000000; // 1MB chunks + $numChunks = 3; + $totalSize = $chunkSize * $numChunks; + + $generator = (function () use ($chunkSize, $numChunks) { + for ($i = 0; $i < $numChunks; $i++) { + yield str_repeat(chr(65 + $i), $chunkSize); + } + })(); + + ob_start(); + + @$this->response->stream($generator, $totalSize); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals($totalSize, strlen($output)); + $this->assertEquals(str_repeat('A', $chunkSize), substr($output, 0, $chunkSize)); + $this->assertEquals(str_repeat('B', $chunkSize), substr($output, $chunkSize, $chunkSize)); + $this->assertEquals(str_repeat('C', $chunkSize), substr($output, $chunkSize * 2, $chunkSize)); + $this->assertEquals($totalSize, $this->response->getSize()); + } + + public function testStreamWithCallableMultipleChunks() + { + // Data larger than CHUNK_SIZE to test offset/length loop + $data = str_repeat('X', Response::CHUNK_SIZE + 500); + + ob_start(); + + @$this->response->stream(function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, strlen($data)); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals($data, $output); + $this->assertEquals(strlen($data), $this->response->getSize()); + } + + public function testStreamDoesNotSendTwice() + { + $generator = (function () { + yield 'first'; + })(); + + ob_start(); + + @$this->response->stream($generator, 5); + + // Try streaming again — should be a no-op + $secondGenerator = (function () { + yield 'second'; + })(); + + @$this->response->stream($secondGenerator, 6); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals('first', $output); + } + + public function testStreamWithDisabledPayload() + { + $this->response->disablePayload(); + + $generator = (function () { + yield 'should not appear'; + })(); + + ob_start(); + + @$this->response->stream($generator, 20); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals('', $output); + $this->assertTrue($this->response->isSent()); + } + + public function testStreamWithEmptyGenerator() + { + $generator = (function () { + return; + yield; // make it a generator + })(); + + ob_start(); + + @$this->response->stream($generator, 0); + + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals('', $output); + $this->assertTrue($this->response->isSent()); + $this->assertEquals(0, $this->response->getSize()); + } + + public function testStreamSetsContentLengthHeader() + { + $data = 'test content'; + + $generator = (function () use ($data) { + yield $data; + })(); + + ob_start(); + + @$this->response->stream($generator, strlen($data)); + + ob_end_clean(); + + $headers = $this->response->getHeaders(); + $this->assertEquals((string) strlen($data), $headers['Content-Length']); + } } From 345eaa62c3e378cb62ce27c253d3408e0fb5fff3 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 12 Mar 2026 03:32:34 +0000 Subject: [PATCH 2/7] fix: Correct generator type hint in testStreamWithEmptyGenerator --- tests/ResponseTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 56dee7e..6d6dd82 100755 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -301,9 +301,9 @@ public function testStreamWithDisabledPayload() public function testStreamWithEmptyGenerator() { - $generator = (function () { - return; - yield; // make it a generator + /** @var \Generator $generator */ + $generator = (function (): \Generator { + yield from []; })(); ob_start(); From 1f954ebf4c87341251e0e48e2fde93bee083ea34 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 12 Mar 2026 04:23:08 +0000 Subject: [PATCH 3/7] feat: Enhance streaming capabilities with detach option for Content-Length preservation --- src/Http/Adapter/Swoole/Response.php | 91 +++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/src/Http/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index b489b2a..62f0688 100644 --- a/src/Http/Adapter/Swoole/Response.php +++ b/src/Http/Adapter/Swoole/Response.php @@ -22,6 +22,15 @@ class Response extends UtopiaResponse */ protected ?SwooleServer $server = null; + /** + * Whether to use detach() + raw TCP send for streaming. + * When true (and server is set): preserves Content-Length header so browsers show download progress. + * When false: uses Swoole's write() which forces chunked Transfer-Encoding. + * + * @var bool + */ + protected bool $detach = true; + /** * Response constructor. */ @@ -44,6 +53,24 @@ public function setServer(SwooleServer $server): static return $this; } + /** + * Set whether to detach from Swoole's HTTP layer for streaming. + * + * When enabled (default): uses detach() + $server->send() to preserve + * Content-Length so browsers show download progress bars. + * + * When disabled: uses Swoole's write() which applies chunked Transfer-Encoding. + * + * @param bool $detach + * @return static + */ + public function setDetach(bool $detach): static + { + $this->detach = $detach; + + return $this; + } + /** * Write * @@ -85,13 +112,6 @@ public function stream(callable|\Generator $source, int $totalSize): void $this->sent = true; - if ($this->server === null) { - $this->sent = false; - parent::stream($source, $totalSize); - - return; - } - if ($this->disablePayload) { $this->appendCookies(); $this->appendHeaders(); @@ -101,7 +121,60 @@ public function stream(callable|\Generator $source, int $totalSize): void return; } - // Build raw HTTP response headers for direct TCP send + // When detach is enabled and server is available, use raw TCP streaming + // to preserve Content-Length (browsers show download progress). + if ($this->detach && $this->server !== null) { + $this->streamDetached($source, $totalSize); + + return; + } + + // Non-detach path: use Swoole's write() (chunked Transfer-Encoding) + $this->addHeader('X-Debug-Speed', (string) (microtime(true) - $this->startTime), override: true); + + if (!empty($this->contentType)) { + $this->addHeader('Content-Type', $this->contentType, override: true); + } + + $this->appendCookies(); + $this->appendHeaders(); + $this->sendStatus($this->statusCode); + + if ($source instanceof \Generator) { + foreach ($source as $chunk) { + if (!empty($chunk)) { + $this->size += strlen($chunk); + if ($this->swoole->write($chunk) === false) { + break; + } + } + } + } else { + $length = self::CHUNK_SIZE; + for ($offset = 0; $offset < $totalSize; $offset += $length) { + $chunk = $source($offset, min($length, $totalSize - $offset)); + if (!empty($chunk)) { + $this->size += strlen($chunk); + if ($this->swoole->write($chunk) === false) { + break; + } + } + } + } + + $this->swoole->end(); + $this->disablePayload(); + } + + /** + * Stream using detach() + raw TCP send to preserve Content-Length. + * + * @param callable|\Generator $source + * @param int $totalSize + * @return void + */ + protected function streamDetached(callable|\Generator $source, int $totalSize): void + { $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); @@ -148,7 +221,6 @@ public function stream(callable|\Generator $source, int $totalSize): void $rawHeaders .= "\r\n"; - // Detach from Swoole HTTP layer and send raw TCP $fd = $this->swoole->fd; $this->swoole->detach(); @@ -158,7 +230,6 @@ public function stream(callable|\Generator $source, int $totalSize): void return; } - // Stream body chunks if ($source instanceof \Generator) { foreach ($source as $chunk) { if (!empty($chunk)) { From bff2f3c2eaf578bf62081cc69e07a051be49435f Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 12 Mar 2026 05:08:45 +0000 Subject: [PATCH 4/7] feat: Add streaming endpoints and Swoole server integration with tests --- Dockerfile.swoole | 20 ++++ docker-compose.yml | 11 +- tests/e2e/Client.php | 5 +- tests/e2e/ResponseTest.php | 28 +++++ tests/e2e/SwooleResponseTest.php | 79 +++++++++++++ tests/e2e/server.php | 43 +++++++ tests/e2e/swoole-server.php | 194 +++++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 Dockerfile.swoole create mode 100644 tests/e2e/SwooleResponseTest.php create mode 100644 tests/e2e/swoole-server.php diff --git a/Dockerfile.swoole b/Dockerfile.swoole new file mode 100644 index 0000000..5b3d752 --- /dev/null +++ b/Dockerfile.swoole @@ -0,0 +1,20 @@ +FROM phpswoole/swoole:php8.3-alpine + +ARG TESTING=true +ENV TESTING=$TESTING + +WORKDIR /usr/src/code + +COPY composer.* /usr/src/code/ + +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist \ + `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` + +COPY ./src /usr/src/code/src +COPY ./tests /usr/src/code/tests +COPY ./phpunit.xml /usr/src/code/phpunit.xml + +EXPOSE 80 + +CMD ["php", "/usr/src/code/tests/e2e/swoole-server.php"] diff --git a/docker-compose.yml b/docker-compose.yml index 6326fe3..8936472 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-web: + build: + context: . + dockerfile: Dockerfile.swoole + ports: + - "9021:80" + volumes: + - ./src:/usr/src/code/src + - ./tests:/usr/src/code/tests \ No newline at end of file diff --git a/tests/e2e/Client.php b/tests/e2e/Client.php index 03bc1e2..fba0c3e 100644 --- a/tests/e2e/Client.php +++ b/tests/e2e/Client.php @@ -34,8 +34,11 @@ class Client /** * SDK constructor. */ - public function __construct() + public function __construct(?string $baseUrl = null) { + if ($baseUrl !== null) { + $this->baseUrl = $baseUrl; + } } /** diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php index 4c7b0c1..76bd460 100644 --- a/tests/e2e/ResponseTest.php +++ b/tests/e2e/ResponseTest.php @@ -168,4 +168,32 @@ public function testSetCookie() $this->assertEquals('value1', $response['cookies']['key1']); $this->assertEquals('value2', $response['cookies']['key2']); } + + // ── Streaming tests (FPM adapter) ── + + public function testStreamWithGenerator(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/generator'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('chunk1-chunk2-chunk3', $response['body']); + } + + public function testStreamWithCallable(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/callable'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(str_repeat('A', 1000), $response['body']); + } + + public function testStreamWithGeneratorLargeData(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/generator-large'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(500000, strlen($response['body'])); + $this->assertEquals(str_repeat('A', 100000), substr($response['body'], 0, 100000)); + $this->assertEquals(str_repeat('E', 100000), substr($response['body'], 400000, 100000)); + } } diff --git a/tests/e2e/SwooleResponseTest.php b/tests/e2e/SwooleResponseTest.php new file mode 100644 index 0000000..cc18011 --- /dev/null +++ b/tests/e2e/SwooleResponseTest.php @@ -0,0 +1,79 @@ +client = new Client('http://' . $host); + } + + // ── Detach mode (default): preserves Content-Length ── + + public function testDetachStreamGeneratorHasContentLength(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/generator'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('chunk1-chunk2-chunk3', $response['body']); + $this->assertArrayHasKey('content-length', $response['headers']); + $this->assertEquals('19', $response['headers']['content-length']); + } + + public function testDetachStreamCallableHasContentLength(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/callable'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(str_repeat('A', 1000), $response['body']); + $this->assertArrayHasKey('content-length', $response['headers']); + $this->assertEquals('1000', $response['headers']['content-length']); + } + + public function testDetachStreamGeneratorLargeDataHasContentLength(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/generator-large'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(500000, strlen($response['body'])); + $this->assertEquals('500000', $response['headers']['content-length']); + $this->assertEquals(str_repeat('A', 100000), substr($response['body'], 0, 100000)); + $this->assertEquals(str_repeat('E', 100000), substr($response['body'], 400000, 100000)); + } + + // ── Non-detach mode: chunked Transfer-Encoding, no Content-Length ── + + public function testNonDetachStreamGenerator(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/non-detach/generator'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('nd-chunk1-nd-chunk2-nd-chunk3', $response['body']); + $this->assertArrayNotHasKey('content-length', $response['headers']); + } + + public function testNonDetachStreamCallable(): void + { + $response = $this->client->call(Client::METHOD_GET, '/stream/non-detach/callable'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(str_repeat('B', 1000), $response['body']); + $this->assertArrayNotHasKey('content-length', $response['headers']); + } +} diff --git a/tests/e2e/server.php b/tests/e2e/server.php index 59404c2..7b044ad 100644 --- a/tests/e2e/server.php +++ b/tests/e2e/server.php @@ -111,6 +111,49 @@ $response->send('Action response'); }); +// ── Streaming endpoints ── + +Http::get('/stream/generator') + ->inject('response') + ->action(function (Response $response) { + $chunks = ['chunk1-', 'chunk2-', 'chunk3']; + $totalSize = array_sum(array_map('strlen', $chunks)); + + $generator = (function () use ($chunks) { + foreach ($chunks as $chunk) { + yield $chunk; + } + })(); + + $response->stream($generator, $totalSize); + }); + +Http::get('/stream/callable') + ->inject('response') + ->action(function (Response $response) { + $data = str_repeat('A', 1000); + + $response->stream(function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, strlen($data)); + }); + +Http::get('/stream/generator-large') + ->inject('response') + ->action(function (Response $response) { + $chunkSize = 100000; // 100KB per chunk + $numChunks = 5; + $totalSize = $chunkSize * $numChunks; + + $generator = (function () use ($chunkSize, $numChunks) { + for ($i = 0; $i < $numChunks; $i++) { + yield str_repeat(chr(65 + $i), $chunkSize); + } + })(); + + $response->stream($generator, $totalSize); + }); + $request = new Request(); $response = new Response(); diff --git a/tests/e2e/swoole-server.php b/tests/e2e/swoole-server.php new file mode 100644 index 0000000..f68d6fb --- /dev/null +++ b/tests/e2e/swoole-server.php @@ -0,0 +1,194 @@ +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 +$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'); + }); + +// ── Streaming endpoints ── + +Http::get('/stream/generator') + ->inject('response') + ->action(function (Response $response) { + $chunks = ['chunk1-', 'chunk2-', 'chunk3']; + $totalSize = array_sum(array_map('strlen', $chunks)); + + $generator = (function () use ($chunks) { + foreach ($chunks as $chunk) { + yield $chunk; + } + })(); + + $response->stream($generator, $totalSize); + }); + +Http::get('/stream/callable') + ->inject('response') + ->action(function (Response $response) { + $data = str_repeat('A', 1000); + + $response->stream(function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, strlen($data)); + }); + +Http::get('/stream/generator-large') + ->inject('response') + ->action(function (Response $response) { + $chunkSize = 100000; // 100KB per chunk + $numChunks = 5; + $totalSize = $chunkSize * $numChunks; + + $generator = (function () use ($chunkSize, $numChunks) { + for ($i = 0; $i < $numChunks; $i++) { + yield str_repeat(chr(65 + $i), $chunkSize); + } + })(); + + $response->stream($generator, $totalSize); + }); + +Http::get('/stream/non-detach/generator') + ->inject('response') + ->action(function (Response $response) { + $response->setDetach(false); + + $chunks = ['nd-chunk1-', 'nd-chunk2-', 'nd-chunk3']; + $totalSize = array_sum(array_map('strlen', $chunks)); + + $generator = (function () use ($chunks) { + foreach ($chunks as $chunk) { + yield $chunk; + } + })(); + + $response->stream($generator, $totalSize); + }); + +Http::get('/stream/non-detach/callable') + ->inject('response') + ->action(function (Response $response) { + $response->setDetach(false); + + $data = str_repeat('B', 1000); + + $response->stream(function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, strlen($data)); + }); + +$server = new Server('0.0.0.0', '80'); +$app = new Http('UTC'); +$app->setCompression(true); +$server->onStart(function () { + echo 'Swoole server started on port 80' . PHP_EOL; +}); +$server->onRequest(function (Request $request, Response $response) use ($app) { + $app->run($request, $response); +}); +$server->start(); From 889f6d5a2d280c7b580c1e110f79a9e9f32b0653 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 12 Mar 2026 05:39:20 +0000 Subject: [PATCH 5/7] feat: Refactor test workflows and consolidate route definitions for FPM and Swoole servers --- .github/workflows/test.yml | 15 +++- tests/e2e/routes.php | 152 ++++++++++++++++++++++++++++++++++++ tests/e2e/server.php | 144 +--------------------------------- tests/e2e/swoole-server.php | 148 ++--------------------------------- 4 files changed, 173 insertions(+), 286 deletions(-) create mode 100644 tests/e2e/routes.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95ff2f2..004570c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,8 +18,17 @@ jobs: - name: Setup Docker run: docker compose up -d --build - - name: Wait for Server to be ready - run: sleep 10 + - name: Wait for FPM server to be ready + run: | + echo "Waiting for FPM server..." + timeout 30 bash -c 'until curl -sf http://localhost:9020/ > /dev/null 2>&1; do sleep 1; done' + echo "FPM server ready" + + - name: Wait for Swoole server to be ready + run: | + echo "Waiting for Swoole server..." + timeout 30 bash -c 'until curl -sf http://localhost:9021/ > /dev/null 2>&1; do sleep 1; done' + echo "Swoole server ready" - name: Run Tests - run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml \ No newline at end of file + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml diff --git a/tests/e2e/routes.php b/tests/e2e/routes.php new file mode 100644 index 0000000..eab251d --- /dev/null +++ b/tests/e2e/routes.php @@ -0,0 +1,152 @@ +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'); + }); + +// ── Streaming endpoints ── + +Http::get('/stream/generator') + ->inject('response') + ->action(function (Response $response) { + $chunks = ['chunk1-', 'chunk2-', 'chunk3']; + $totalSize = array_sum(array_map('strlen', $chunks)); + + $generator = (function () use ($chunks) { + foreach ($chunks as $chunk) { + yield $chunk; + } + })(); + + $response->stream($generator, $totalSize); + }); + +Http::get('/stream/callable') + ->inject('response') + ->action(function (Response $response) { + $data = str_repeat('A', 1000); + + $response->stream(function (int $offset, int $length) use ($data) { + return substr($data, $offset, $length); + }, strlen($data)); + }); + +Http::get('/stream/generator-large') + ->inject('response') + ->action(function (Response $response) { + $chunkSize = 100000; // 100KB per chunk + $numChunks = 5; + $totalSize = $chunkSize * $numChunks; + + $generator = (function () use ($chunkSize, $numChunks) { + for ($i = 0; $i < $numChunks; $i++) { + yield str_repeat(chr(65 + $i), $chunkSize); + } + })(); + + $response->stream($generator, $totalSize); + }); diff --git a/tests/e2e/server.php b/tests/e2e/server.php index 7b044ad..1876213 100644 --- a/tests/e2e/server.php +++ b/tests/e2e/server.php @@ -1,11 +1,10 @@ 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'); - }); - -// ── Streaming endpoints ── - -Http::get('/stream/generator') - ->inject('response') - ->action(function (Response $response) { - $chunks = ['chunk1-', 'chunk2-', 'chunk3']; - $totalSize = array_sum(array_map('strlen', $chunks)); - - $generator = (function () use ($chunks) { - foreach ($chunks as $chunk) { - yield $chunk; - } - })(); - - $response->stream($generator, $totalSize); - }); - -Http::get('/stream/callable') - ->inject('response') - ->action(function (Response $response) { - $data = str_repeat('A', 1000); - - $response->stream(function (int $offset, int $length) use ($data) { - return substr($data, $offset, $length); - }, strlen($data)); - }); - -Http::get('/stream/generator-large') - ->inject('response') - ->action(function (Response $response) { - $chunkSize = 100000; // 100KB per chunk - $numChunks = 5; - $totalSize = $chunkSize * $numChunks; - - $generator = (function () use ($chunkSize, $numChunks) { - for ($i = 0; $i < $numChunks; $i++) { - yield str_repeat(chr(65 + $i), $chunkSize); - } - })(); - - $response->stream($generator, $totalSize); - }); +require_once __DIR__ . '/routes.php'; $request = new Request(); $response = new Response(); diff --git a/tests/e2e/swoole-server.php b/tests/e2e/swoole-server.php index f68d6fb..69cce74 100644 --- a/tests/e2e/swoole-server.php +++ b/tests/e2e/swoole-server.php @@ -3,159 +3,24 @@ require_once __DIR__ . '/../../vendor/autoload.php'; use Utopia\Http\Http; +use Utopia\Http\Response; use Utopia\Http\Adapter\Swoole\Request; -use Utopia\Http\Adapter\Swoole\Response; +use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Http\Adapter\Swoole\Server; -use Utopia\Validator\Text; ini_set('memory_limit', '1024M'); ini_set('display_errors', '1'); ini_set('display_startup_errors', '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 -$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'); - }); - -// ── Streaming endpoints ── - -Http::get('/stream/generator') - ->inject('response') - ->action(function (Response $response) { - $chunks = ['chunk1-', 'chunk2-', 'chunk3']; - $totalSize = array_sum(array_map('strlen', $chunks)); +require_once __DIR__ . '/routes.php'; - $generator = (function () use ($chunks) { - foreach ($chunks as $chunk) { - yield $chunk; - } - })(); - - $response->stream($generator, $totalSize); - }); - -Http::get('/stream/callable') - ->inject('response') - ->action(function (Response $response) { - $data = str_repeat('A', 1000); - - $response->stream(function (int $offset, int $length) use ($data) { - return substr($data, $offset, $length); - }, strlen($data)); - }); - -Http::get('/stream/generator-large') - ->inject('response') - ->action(function (Response $response) { - $chunkSize = 100000; // 100KB per chunk - $numChunks = 5; - $totalSize = $chunkSize * $numChunks; - - $generator = (function () use ($chunkSize, $numChunks) { - for ($i = 0; $i < $numChunks; $i++) { - yield str_repeat(chr(65 + $i), $chunkSize); - } - })(); - - $response->stream($generator, $totalSize); - }); +// ── Swoole-only: non-detach streaming endpoints ── Http::get('/stream/non-detach/generator') ->inject('response') ->action(function (Response $response) { + /** @var SwooleResponse $response */ $response->setDetach(false); $chunks = ['nd-chunk1-', 'nd-chunk2-', 'nd-chunk3']; @@ -173,6 +38,7 @@ Http::get('/stream/non-detach/callable') ->inject('response') ->action(function (Response $response) { + /** @var SwooleResponse $response */ $response->setDetach(false); $data = str_repeat('B', 1000); @@ -188,7 +54,7 @@ $server->onStart(function () { echo 'Swoole server started on port 80' . PHP_EOL; }); -$server->onRequest(function (Request $request, Response $response) use ($app) { +$server->onRequest(function (Request $request, SwooleResponse $response) use ($app) { $app->run($request, $response); }); $server->start(); From 44d4d4c3249914a2d4c009aec9bb05c009e5f9af Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 12 Mar 2026 06:14:25 +0000 Subject: [PATCH 6/7] feat: Enhance Swoole request handling with cookie reconstruction and add error handler for 404 responses --- src/Http/Adapter/Swoole/Request.php | 14 ++++++++++- src/Http/Http.php | 2 ++ tests/e2e/ResponseTest.php | 6 ++--- tests/e2e/SwooleResponseTest.php | 38 ++++++++++++++++++++++++++++- tests/e2e/routes.php | 11 +++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/Http/Adapter/Swoole/Request.php b/src/Http/Adapter/Swoole/Request.php index 094f81c..2d97fda 100644 --- a/src/Http/Adapter/Swoole/Request.php +++ b/src/Http/Adapter/Swoole/Request.php @@ -374,6 +374,18 @@ protected function generateInput(): array */ protected function generateHeaders(): array { - return $this->swoole->header; + $headers = $this->swoole->header ?? []; + + // Swoole parses the Cookie header into $swoole->cookie and removes it + // from the header array. Reconstruct it so callers can access it. + if (!isset($headers['cookie']) && !empty($this->swoole->cookie)) { + $pairs = []; + foreach ($this->swoole->cookie as $k => $v) { + $pairs[] = $k . '=' . $v; + } + $headers['cookie'] = implode('; ', $pairs); + } + + return $headers; } } diff --git a/src/Http/Http.php b/src/Http/Http.php index c031750..0a3b3dd 100755 --- a/src/Http/Http.php +++ b/src/Http/Http.php @@ -885,6 +885,8 @@ public function run(Request $request, Response $response): static */ private function runInternal(Request $request, Response $response): static { + $this->route = null; + if ($this->compression) { $response->setAcceptEncoding($request->getHeader('accept-encoding', '')); $response->setCompressionMinSize($this->compressionMinSize); diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php index 76bd460..2c9a062 100644 --- a/tests/e2e/ResponseTest.php +++ b/tests/e2e/ResponseTest.php @@ -111,15 +111,15 @@ public function testDoubleSlash() $this->assertEquals('Hello World!', $response['body']); $response = $this->client->call(Client::METHOD_GET, '//value/123'); - $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(404, $response['headers']['status-code']); $this->assertEmpty($response['body']); $response = $this->client->call(Client::METHOD_GET, '/value//123'); - $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(400, $response['headers']['status-code']); $this->assertEmpty($response['body']); $response = $this->client->call(Client::METHOD_GET, '//value//123'); - $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(404, $response['headers']['status-code']); $this->assertEmpty($response['body']); } diff --git a/tests/e2e/SwooleResponseTest.php b/tests/e2e/SwooleResponseTest.php index cc18011..c8e7b5c 100644 --- a/tests/e2e/SwooleResponseTest.php +++ b/tests/e2e/SwooleResponseTest.php @@ -33,7 +33,7 @@ public function testDetachStreamGeneratorHasContentLength(): void $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('chunk1-chunk2-chunk3', $response['body']); $this->assertArrayHasKey('content-length', $response['headers']); - $this->assertEquals('19', $response['headers']['content-length']); + $this->assertEquals('20', $response['headers']['content-length']); } public function testDetachStreamCallableHasContentLength(): void @@ -68,6 +68,42 @@ public function testNonDetachStreamGenerator(): void $this->assertArrayNotHasKey('content-length', $response['headers']); } + /** + * Override: Swoole parses cookies internally and the reconstructed Cookie header + * always uses '; ' separator, so 'cookie1=value1;cookie2=value2' becomes + * 'cookie1=value1; cookie2=value2'. We test the normalized format here. + */ + public function testCookie() + { + // One cookie + $cookie = 'cookie1=value1'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Two cookies + $cookie = 'cookie1=value1; cookie2=value2'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Two cookies without optional space (Swoole normalizes to '; ') + $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => 'cookie1=value1;cookie2=value2']); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('cookie1=value1; cookie2=value2', $response['body']); + + // Cookie with "=" in value + $cookie = 'cookie1=value1=value2'; + $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => $cookie]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($cookie, $response['body']); + + // Case sensitivity for cookie names (Swoole lowercases keys) + $response = $this->client->call(Client::METHOD_GET, '/cookies', ['Cookie' => 'cookie1=v1; Cookie1=v2']); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + } + public function testNonDetachStreamCallable(): void { $response = $this->client->call(Client::METHOD_GET, '/stream/non-detach/callable'); diff --git a/tests/e2e/routes.php b/tests/e2e/routes.php index eab251d..49741fb 100644 --- a/tests/e2e/routes.php +++ b/tests/e2e/routes.php @@ -108,6 +108,17 @@ $response->send('Action response'); }); +// ── Error handler (required for Swoole to close the connection on 404) ── + +Http::error() + ->inject('error') + ->inject('response') + ->action(function (\Throwable $error, Response $response) { + $response + ->setStatusCode($error->getCode() ?: 500) + ->send(''); + }); + // ── Streaming endpoints ── Http::get('/stream/generator') From a9372e58848c85bbf0bd8c1a4da2fdef37b339ed Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 12 Mar 2026 06:26:55 +0000 Subject: [PATCH 7/7] feat: Update CI workflow and Docker configuration for improved testing and health checks --- .github/workflows/test.yml | 20 +++++++------------- docker-compose.yml | 13 ++++++++++++- phpunit.xml | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 004570c..f44c909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,19 +16,13 @@ jobs: php-version: '8.1' - name: Setup Docker - run: docker compose up -d --build + run: docker compose up -d --build --wait - - name: Wait for FPM server to be ready - run: | - echo "Waiting for FPM server..." - timeout 30 bash -c 'until curl -sf http://localhost:9020/ > /dev/null 2>&1; do sleep 1; done' - echo "FPM server ready" + - name: Run Unit Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite unit - - name: Wait for Swoole server to be ready - run: | - echo "Waiting for Swoole server..." - timeout 30 bash -c 'until curl -sf http://localhost:9021/ > /dev/null 2>&1; do sleep 1; done' - echo "Swoole server ready" + - name: Run FPM E2E Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite fpm - - name: Run Tests - run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml + - name: Run Swoole E2E Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite swoole diff --git a/docker-compose.yml b/docker-compose.yml index 8936472..b37251b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,12 @@ services: volumes: - ./src:/usr/share/nginx/html/src - ./tests:/usr/share/nginx/html/tests + - ./phpunit.xml:/usr/share/nginx/html/phpunit.xml + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1/ || exit 1"] + interval: 2s + timeout: 5s + retries: 15 swoole-web: build: context: . @@ -14,4 +20,9 @@ services: - "9021:80" volumes: - ./src:/usr/src/code/src - - ./tests:/usr/src/code/tests \ No newline at end of file + - ./tests:/usr/src/code/tests + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1/ || exit 1"] + interval: 2s + timeout: 5s + retries: 15 diff --git a/phpunit.xml b/phpunit.xml index de6deb0..8bc8156 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,9 +9,19 @@ stopOnFailure="false" > - + + ./tests/ + ./tests/e2e + + + ./tests/e2e/Client.php + ./tests/e2e/ + ./tests/e2e/SwooleResponseTest.php + + ./tests/e2e/Client.php - ./tests/ + ./tests/e2e/ResponseTest.php + ./tests/e2e/SwooleResponseTest.php - \ No newline at end of file +