diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95ff2f2..f44c909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +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 Server to be ready - run: sleep 10 + - name: Run Unit Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite unit - - name: Run Tests - run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml \ No newline at end of file + - name: Run FPM E2E Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite fpm + + - name: Run Swoole E2E Tests + run: docker compose exec web vendor/bin/phpunit --configuration phpunit.xml --testsuite swoole 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..b37251b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,24 @@ 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 + - ./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: . + dockerfile: Dockerfile.swoole + ports: + - "9021:80" + volumes: + - ./src:/usr/src/code/src + - ./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 + 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/Adapter/Swoole/Response.php b/src/Http/Adapter/Swoole/Response.php index 95188e1..62f0688 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,22 @@ 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; + + /** + * 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. */ @@ -23,6 +40,37 @@ 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; + } + + /** + * 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 * @@ -45,6 +93,169 @@ 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->disablePayload) { + $this->appendCookies(); + $this->appendHeaders(); + $this->swoole->end(); + $this->disablePayload(); + + return; + } + + // 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); + + 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"; + + $fd = $this->swoole->fd; + $this->swoole->detach(); + + if ($this->server->send($fd, $rawHeaders) === false) { + $this->disablePayload(); + + return; + } + + 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/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/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..6d6dd82 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() + { + /** @var \Generator $generator */ + $generator = (function (): \Generator { + yield from []; + })(); + + 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']); + } } 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..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']); } @@ -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..c8e7b5c --- /dev/null +++ b/tests/e2e/SwooleResponseTest.php @@ -0,0 +1,115 @@ +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('20', $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']); + } + + /** + * 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'); + + $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/routes.php b/tests/e2e/routes.php new file mode 100644 index 0000000..49741fb --- /dev/null +++ b/tests/e2e/routes.php @@ -0,0 +1,163 @@ +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'); + }); + +// ── 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') + ->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 59404c2..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'); - }); +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 new file mode 100644 index 0000000..69cce74 --- /dev/null +++ b/tests/e2e/swoole-server.php @@ -0,0 +1,60 @@ +inject('response') + ->action(function (Response $response) { + /** @var SwooleResponse $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) { + /** @var SwooleResponse $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, SwooleResponse $response) use ($app) { + $app->run($request, $response); +}); +$server->start();