Skip to content

Conversation

@abnegate
Copy link
Member

@abnegate abnegate commented Jan 15, 2026

Summary by CodeRabbit

  • New Features
    • Pluggable HTTP adapters (cURL and Swoole) with streaming, redirects, and runtime availability.
  • Bug Fixes
    • Better JSON error reporting and corrected binary/blob handling; more robust text conversion.
  • Tests
    • Extensive adapter and client tests covering GET/POST, redirects, streaming, timeouts, headers, and file uploads.
  • Refactor
    • Client now uses adapter-based architecture and strict typing; default connect timeout reduced.
  • Chores
    • Added Docker, docker-compose, CI workflow, PHPStan tweaks, .gitignore, and dev tooling.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 15, 2026

Walkthrough

Adds a new Adapter interface and two implementations (Curl and Swoole) to standardize HTTP requests with support for streaming chunks, redirects, SSL, proxies, timeouts, and resource cleanup. Refactors Client to accept an Adapter (defaults to Curl) and delegates request sending. Improves Response text/json/blob handling and enables strict types in several files. Adds comprehensive unit tests for both adapters and client changes, updates test router, introduces Dockerfile and docker-compose, switches CI to a Docker-based workflow, updates phpstan, and adds a dev dependency for Swoole IDE helper.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'Add curl/swoole adapters' accurately summarizes the main changes, which introduce Curl and Swoole HTTP adapter implementations as core new features.
Docstring Coverage ✅ Passed Docstring coverage is 96.61% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings


📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7763987 and 7d1f9ea.

📒 Files selected for processing (1)
  • src/Adapter/Swoole.php
🧰 Additional context used
🧬 Code graph analysis (1)
src/Adapter/Swoole.php (4)
src/Chunk.php (2)
  • Chunk (12-67)
  • __construct (20-26)
src/Exception.php (2)
  • Exception (7-21)
  • __construct (13-16)
src/Response.php (3)
  • Response (12-129)
  • __construct (40-48)
  • getStatusCode (72-75)
src/Adapter.php (1)
  • send (26-33)
🔇 Additional comments (6)
src/Adapter/Swoole.php (6)

56-103: LGTM — constructor config and coroutine fallback look solid.


105-123: LGTM — availability check and sync-client indirection are straightforward.


154-170: LGTM — client close and cache eviction are clean.


225-238: LGTM — settings merge is clear and keeps defaults intact.


392-431: LGTM — coroutine wrapper and error propagation are handled cleanly.


433-442: LGTM — destructor cleans up cached clients reliably.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/Adapter/Curl.php`:
- Around line 48-52: CURLOPT_POSTFIELDS is being set unconditionally in the
$curlOptions array which can turn GET requests with an empty $body into requests
with a body; update the Curl.php code that builds $curlOptions so
CURLOPT_POSTFIELDS is only added after the initial array when ($body !== null &&
$body !== [] && $body !== '') or when the HTTP $method requires a body (e.g.,
POST, PUT, PATCH), by removing CURLOPT_POSTFIELDS from the initial $curlOptions
and conditionally assigning $curlOptions[CURLOPT_POSTFIELDS] when those checks
pass.

In `@src/Adapter/Swoole.php`:
- Around line 157-173: The code shadows the method parameter $body by assigning
$body = $client->body ?? '', causing the original request body to be lost on
redirects; rename the local variable (e.g., $responseBodyFromClient or
$responseBodyString) and use that renamed variable in the chunk creation and the
else branch (replace current uses of the overwritten $body there), leaving the
original $body parameter intact so the later check (if ($body !== null)) still
sees the original request body; update references to $chunkCallback and
$responseBody accordingly (e.g., create Chunk with data: $responseBodyFromClient
and set $responseBody = $responseBodyFromClient).
- Around line 128-132: The call to Swoole's addData currently passes form key
then value, but addData expects the data first and the field name second; in the
block handling $hasFiles inside the Swoole adapter (method using
$client->addData), swap the arguments so you call addData($value, $key) instead
of addData($key, $value), and check for any other occurrences of addData in the
Swoole adapter to apply the same argument order fix.

In `@tests/Adapter/CurlTest.php`:
- Around line 1-11: Tests in CurlTest hardcode requests to localhost:8001 and
share the production namespace; update tests/Adapter/CurlTest.php: change any
hardcoded base URL or port references in class CurlTest from 8001 to 8000 (so
they match the CI server started by php -S localhost:8000), and change the
namespace declaration from Utopia\Fetch\Adapter to a test-specific namespace
such as Utopia\Tests\Fetch\Adapter (and update any uses/imports inside the file
accordingly); ensure other test files that target the same server port are
updated similarly.

In `@tests/Adapter/SwooleTest.php`:
- Line 3: The test file currently declares the production namespace
"Utopia\Fetch\Adapter" which will conflict with source code; change the
namespace in the file to a test namespace (for example
"Utopia\Fetch\Tests\Adapter" or "Tests\Utopia\Fetch\Adapter") and ensure your
composer.json autoload-dev PSR-4 mapping matches that test namespace so the test
class is loaded correctly.
🧹 Nitpick comments (9)
src/Exception.php (1)

13-13: Consider adding type hint to constructor parameter.

With declare(strict_types=1) enabled, the $message parameter should have a string type hint to be fully consistent.

Suggested fix
-    public function __construct($message)
+    public function __construct(string $message)
src/Adapter.php (1)

22-22: Consider documenting the chunk callback signature.

The docblock could be more specific about the expected callback signature for better IDE support and developer experience.

Suggested enhancement
-     * `@param` callable|null $chunkCallback Optional callback for streaming chunks
+     * `@param` callable(Chunk): void|null $chunkCallback Optional callback for streaming chunks, receives Chunk objects
src/Adapter/Curl.php (2)

53-61: Unused $curl parameter is expected.

The $curl parameter in CURLOPT_HEADERFUNCTION is required by cURL's callback signature but isn't needed here. This is a false positive from static analysis. You can suppress it with @param resource $curl in a docblock or prefix with underscore ($_curl).


62-75: Response body is empty when using chunkCallback.

When a chunkCallback is provided, chunks are passed to the callback but $responseBody remains empty. The returned Response will have an empty body, which may be unexpected for callers who want both streaming and the final body.

Consider documenting this behavior or accumulating the body even when streaming:

📝 Option: Accumulate body alongside chunk callback
             CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($chunkCallback, &$responseBody, &$chunkIndex) {
+                $responseBody .= $data;
                 if ($chunkCallback !== null) {
                     $chunk = new Chunk(
                         data: $data,
                         size: strlen($data),
                         timestamp: microtime(true),
                         index: $chunkIndex++
                     );
                     $chunkCallback($chunk);
-                } else {
-                    $responseBody .= $data;
                 }
                 return strlen($data);
             },

Also applies to: 96-100

src/Client.php (1)

299-299: GraphQL validation with inline throw is functional but impacts readability.

The inline throw within a match expression works but is less readable. Consider extracting to a helper method or validating before the match.

♻️ Alternative: Extract validation
         if (is_array($body) && isset($this->headers['content-type'])) {
+            if ($this->headers['content-type'] === self::CONTENT_TYPE_GRAPHQL) {
+                if (!isset($body['query']) || !is_string($body['query'])) {
+                    throw new Exception('GraphQL body must contain a "query" field with a string value');
+                }
+            }
             $body = match ($this->headers['content-type']) {
                 self::CONTENT_TYPE_APPLICATION_JSON => $this->jsonEncode($body),
                 self::CONTENT_TYPE_APPLICATION_FORM_URLENCODED, self::CONTENT_TYPE_MULTIPART_FORM_DATA => self::flatten($body),
-                self::CONTENT_TYPE_GRAPHQL => isset($body['query']) && is_string($body['query']) ? $body['query'] : throw new Exception('GraphQL body must contain a "query" field with a string value'),
+                self::CONTENT_TYPE_GRAPHQL => $body['query'],
                 default => $body,
             };
         }
tests/Adapter/SwooleTest.php (3)

26-28: Redundant null checks after setUp skip.

When markTestSkipped() is called in setUp(), PHPUnit stops test execution, so the $this->adapter === null checks in each test method are unnecessary. While defensive, they add noise.

♻️ Consider removing redundant checks

Since setUp() already calls markTestSkipped() when Swoole is unavailable, the adapter will never be null when test methods execute. You can safely remove these checks:

     public function testGetRequest(): void
     {
-        if ($this->adapter === null) {
-            $this->markTestSkipped('Swoole extension is not installed');
-        }
-
         $response = $this->adapter->send(

Also applies to: 55-57, 86-88


35-41: Inconsistent connectTimeout values across tests.

Tests use connectTimeout: 60000 (60 seconds) while the Curl adapter and Client default to 5000ms (5 seconds). Consider aligning these values for consistency, or use a class constant for test timeouts.

Also applies to: 65-71, 95-101


262-270: Test logic may produce unexpected results.

If Swoole is not available, setUp() marks the test as skipped and $this->adapter remains null. This test then checks if $classExists is false and asserts $this->adapter is null, but the test would have been skipped before reaching this point. The test only meaningfully executes when Swoole is available.

♻️ Simplified availability test
     public function testSwooleAvailability(): void
     {
-        $classExists = class_exists('Swoole\Coroutine\Http\Client');
-        if ($classExists) {
-            $this->assertNotNull($this->adapter);
-        } else {
-            $this->assertNull($this->adapter);
-        }
+        // If we reach here, setUp() didn't skip, so Swoole is available
+        $this->assertTrue(Swoole::isAvailable());
+        $this->assertNotNull($this->adapter);
     }
tests/Adapter/CurlTest.php (1)

249-275: Consider adding assertion for file existence.

The test assumes the file exists at the specified path. Adding a pre-condition check would make test failures more informative.

♻️ Optional improvement
     public function testFileUpload(): void
     {
         $filePath = __DIR__ . '/../resources/logo.png';
+        $this->assertFileExists($filePath, 'Test resource file not found');
+        
         $body = [
             'file' => new \CURLFile(strval(realpath($filePath)), 'image/png', 'logo.png')
         ];
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a96a010 and 9cff1be.

📒 Files selected for processing (12)
  • src/Adapter.php
  • src/Adapter/Curl.php
  • src/Adapter/Swoole.php
  • src/Chunk.php
  • src/Client.php
  • src/Exception.php
  • src/Response.php
  • tests/Adapter/CurlTest.php
  • tests/Adapter/SwooleTest.php
  • tests/ClientTest.php
  • tests/ResponseTest.php
  • tests/router.php
🧰 Additional context used
🧬 Code graph analysis (8)
src/Adapter.php (3)
src/Adapter/Curl.php (1)
  • send (31-104)
src/Adapter/Swoole.php (1)
  • send (41-285)
src/Response.php (1)
  • Response (12-115)
tests/ResponseTest.php (1)
src/Response.php (1)
  • blob (107-114)
tests/ClientTest.php (4)
src/Adapter/Curl.php (1)
  • Curl (17-105)
src/Adapter/Swoole.php (1)
  • Swoole (17-286)
src/Client.php (1)
  • Client (13-416)
src/Response.php (1)
  • Response (12-115)
src/Adapter/Curl.php (4)
src/Chunk.php (1)
  • Chunk (12-67)
src/Exception.php (1)
  • Exception (7-21)
src/Response.php (1)
  • Response (12-115)
src/Adapter.php (1)
  • send (26-33)
src/Adapter/Swoole.php (4)
src/Chunk.php (1)
  • Chunk (12-67)
src/Exception.php (1)
  • Exception (7-21)
src/Response.php (2)
  • Response (12-115)
  • getStatusCode (72-75)
src/Adapter.php (1)
  • send (26-33)
src/Response.php (1)
src/Exception.php (1)
  • Exception (7-21)
tests/Adapter/SwooleTest.php (4)
src/Chunk.php (4)
  • Chunk (12-67)
  • getIndex (63-66)
  • getSize (43-46)
  • getData (33-36)
src/Response.php (4)
  • Response (12-115)
  • getStatusCode (72-75)
  • json (91-101)
  • getHeaders (63-66)
src/Adapter/Swoole.php (2)
  • Swoole (17-286)
  • send (41-285)
src/Adapter.php (1)
  • send (26-33)
src/Client.php (5)
src/Adapter/Curl.php (2)
  • Curl (17-105)
  • send (31-104)
src/Chunk.php (1)
  • __construct (20-26)
src/Exception.php (2)
  • __construct (13-16)
  • Exception (7-21)
src/Response.php (1)
  • __construct (40-48)
src/Adapter.php (1)
  • send (26-33)
🪛 GitHub Actions: Tests
src/Adapter/Curl.php

[error] 91-91: Utopia\Fetch\Exception: Failed to connect to localhost port 8001 after 0 ms: Couldn't connect to server

tests/Adapter/CurlTest.php

[error] 24-24: PHPUnit test failed: testGetRequest could not complete due to connection to localhost:8001 being refused


[error] 50-50: PHPUnit test failed: testPostWithJsonBody could not complete due to connection to localhost:8001 being refused


[error] 76-76: PHPUnit test failed: testCustomTimeout could not complete due to connection to localhost:8001 being refused


[error] 99-99: PHPUnit test failed: testRedirectHandling could not complete due to connection to localhost:8001 being refused


[error] 124-124: PHPUnit test failed: testRedirectDisabled could not complete due to connection to localhost:8001 being refused


[error] 148-148: PHPUnit test failed: testChunkCallback could not complete due to connection to localhost:8001 being refused


[error] 183-183: PHPUnit test failed: testFormDataBody could not complete due to connection to localhost:8001 being refused


[error] 206-206: PHPUnit test failed: testResponseHeaders could not complete due to connection to localhost:8001 being refused


[error] 256-256: PHPUnit test failed: testFileUpload could not complete due to connection to localhost:8001 being refused

🪛 GitHub Check: CodeQL
src/Adapter/Swoole.php

[failure] 273-273:
Function Swoole\Coroutine\run not found.

🪛 PHPMD (2.15.0)
src/Adapter/Curl.php

53-53: Avoid unused parameters such as '$curl'. (undefined)

(UnusedFormalParameter)

🪛 PHPStan (2.1.33)
src/Adapter/Swoole.php

273-273: Function Swoole\Coroutine\run not found.
Learn more at https://phpstan.org/user-guide/discovering-symbols

(function.notFound)

🔇 Additional comments (22)
src/Exception.php (1)

3-4: LGTM! Strict types declaration added.

Good addition for type safety consistency across the codebase.

src/Chunk.php (1)

3-4: LGTM!

Strict types declaration added. The class is well-structured with readonly properties and proper documentation.

tests/ResponseTest.php (1)

52-56: LGTM! Test logic now correctly mirrors the Response::blob() implementation.

The updated test properly converts each character to its binary representation using an array, matching the implementation in src/Response.php lines 106-113.

src/Adapter.php (1)

12-34: Well-designed adapter interface.

The interface provides a clean contract for HTTP adapters with appropriate flexibility for body types and optional streaming support. The documentation clearly describes all parameters and options.

tests/router.php (1)

44-46: LGTM! Port updated to 8001 for test consistency.

The redirect URL port change aligns with the standardized port 8001 used consistently across all test files.

src/Response.php (2)

91-101: LGTM! Improved JSON error handling.

Using json_last_error() is the correct approach since json_decode can return null for valid JSON input (e.g., decoding the string "null"). This change properly distinguishes between valid null results and actual decoding errors.


107-114: LGTM! More efficient blob conversion.

Array accumulation with implode() is more efficient than repeated string concatenation, especially for larger bodies.

tests/ClientTest.php (3)

14-20: LGTM! Good test for default adapter behavior.

Validates that the Client correctly defaults to the Curl adapter when no adapter is explicitly provided.


317-380: LGTM! Consistent port update across data providers.

All test URLs correctly updated to port 8001 for consistency with the test server configuration.


36-46: This test is correctly implemented — the Swoole adapter handles coroutine context internally.

The Swoole adapter automatically manages the coroutine context requirement. It checks if already in a coroutine context using \Swoole\Coroutine::getCid() (line 268) and, if not, wraps execution in \Swoole\Coroutine\run() (line 273). Therefore, the test does not need explicit wrapping and will function correctly as written.

Likely an incorrect or invalid review comment.

src/Adapter/Curl.php (1)

87-103: LGTM! Proper error handling and resource cleanup.

Good use of try/finally to ensure curl_close() is always called, preventing resource leaks. The error handling correctly captures cURL errors and throws a domain-specific exception.

src/Client.php (5)

43-53: LGTM! Clean adapter pattern implementation.

The constructor correctly defaults to the Curl adapter when none is provided, maintaining backward compatibility while enabling adapter injection for testing and alternative implementations like Swoole.


33-33: Default connectTimeout reduced from 60s to 5s.

This is a significant change that may affect users with slow network connections or services behind load balancers. Consider documenting this change in release notes.


171-173: LGTM! Correct bitwise OR reduction for JSON flags.

Using array_reduce with bitwise OR is the proper way to combine multiple JSON encode flags.


304-307: LGTM! Robust query parameter handling.

Correctly detects existing query strings and uses the appropriate separator (& vs ?). The rtrim ensures clean URL construction.


309-326: LGTM! Clean delegation to adapter.

The options array is well-structured with proper fallbacks, and the adapter delegation is clean and maintainable.

tests/Adapter/SwooleTest.php (1)

13-19: LGTM! Proper conditional setup for optional extension.

The setUp method correctly skips tests when Swoole is not available, making the test suite portable across environments.

src/Adapter/Swoole.php (2)

24-27: LGTM!

The availability check correctly verifies the Swoole Coroutine HTTP client class exists.


267-274: Swoole coroutine context handling is correct. Add Swoole stubs to PHPStan configuration to suppress false positive warnings.

The Swoole\Coroutine\run() API exists in the Swoole extension. PHPStan and similar static analysis tools report undefined function warnings for Swoole functions because they lack stubs by default.

The coroutine detection logic properly checks whether code is already running inside a coroutine using Swoole\Coroutine::getCid() and creates a scheduler only when necessary.

PHPStan configuration improvement

Add Swoole stubs to phpstan.neon:

parameters:
  level: 8
  paths:
    - src
    - tests
  stubFiles:
    - vendor/swoole/ide-helper/output/swoole.php

Or install the Swoole IDE helper package for stubs (requires composer require --dev swoole/ide-helper).

tests/Adapter/CurlTest.php (3)

14-17: LGTM!

The test setup correctly instantiates a fresh Curl adapter for each test case.


22-42: LGTM!

The basic GET request test properly validates the response instance type, status code, and echoed method from the server.


145-175: Good chunk callback test coverage.

The test properly validates chunk streaming behavior including sequential indices, non-zero sizes, and non-empty data. The assertion on line 167 ensures at least one chunk was received.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

- Fix port mismatch: update all test URLs from 8001 to 8000 to match CI server
- Fix Curl adapter: only set CURLOPT_POSTFIELDS when body is non-empty
- Fix Swoole adapter: correct addData parameter order (should be value, key)
- Fix Swoole adapter: rename $body to $currentResponseBody to avoid shadowing
- Fix phpstan.neon: add reportUnmatchedIgnoredErrors: false
- Fix router.php: add Content-Type header for JSON responses
- Fix ClientTest: handle missing content-type header for GET requests

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@tests/Adapter/SwooleTest.php`:
- Around line 259-270: The testSwooleAvailability() contains an unreachable else
branch because setUp() calls markTestSkipped() when Swoole is absent; update the
test to query availability directly via Swoole::isAvailable() (or equivalent
static helper) and assert that when Swoole::isAvailable() returns true
$this->adapter is not null and when false the test uses markTestSkipped() or
asserts adapter is null accordingly; specifically modify
testSwooleAvailability() to call Swoole::isAvailable() instead of
class_exists('Swoole\Coroutine\Http\Client') and remove the unreachable else
branch and any assumptions about $this->adapter being null when the test runs.
♻️ Duplicate comments (1)
tests/Adapter/SwooleTest.php (1)

3-3: Test namespace should differ from source namespace.

This was flagged previously. The test file uses Utopia\Fetch\Adapter which matches the source namespace rather than a test-specific namespace like Tests\Utopia\Fetch\Adapter.

🧹 Nitpick comments (3)
src/Adapter/Swoole.php (2)

205-239: Consider extracting body setup logic to reduce duplication.

This block duplicates the body handling logic from lines 107-141. While both addData calls now have correct parameter order, extracting this to a private helper method would improve maintainability.

♻️ Proposed refactor - extract body setup helper
private function setupRequestBody(\Swoole\Coroutine\Http\Client $client, mixed $body, array $headers): void
{
    if ($body === null) {
        return;
    }

    if (is_array($body)) {
        $hasFiles = false;
        $formData = [];

        foreach ($body as $key => $value) {
            if ($value instanceof \CURLFile || (is_string($value) && str_starts_with($value, '@'))) {
                $hasFiles = true;
                if ($value instanceof \CURLFile) {
                    $client->addFile($value->getFilename(), $key, $value->getMimeType() ?: 'application/octet-stream', $value->getPostFilename() ?: basename($value->getFilename()));
                } elseif (str_starts_with($value, '@')) {
                    $filePath = substr($value, 1);
                    $client->addFile($filePath, $key);
                }
            } else {
                $formData[$key] = $value;
            }
        }

        if ($hasFiles) {
            foreach ($formData as $key => $value) {
                $client->addData($value, $key);
            }
        } elseif (isset($headers['content-type']) && $headers['content-type'] === 'application/x-www-form-urlencoded') {
            $client->setData(http_build_query($body));
        } else {
            $client->setData($body);
        }
    } else {
        $client->setData($body);
    }
}

Then replace both occurrences (lines 107-141 and 205-239) with:

$this->setupRequestBody($client, $body, $headers);

Note: Since this is within a closure, you may need to pass $this or make it a standalone function within the closure.


276-278: Consider preserving the original exception chain.

Wrapping the exception loses the original stack trace, which can make debugging harder. Consider passing the original exception as the previous parameter.

♻️ Proposed fix
         if ($exception !== null) {
-            throw new Exception($exception->getMessage());
+            throw new Exception($exception->getMessage(), 0, $exception);
         }

Note: This requires updating src/Exception.php to accept the $previous parameter in its constructor.

tests/Adapter/SwooleTest.php (1)

13-19: Redundant null checks in test methods.

When markTestSkipped() is called in setUp(), PHPUnit throws a SkippedTestException that prevents the test method from executing. The null checks at the start of each test method (e.g., lines 26-28, 55-57, 86-88, etc.) are therefore unnecessary since the adapter will never be null when a test actually runs.

Consider removing these redundant checks to simplify the tests:

♻️ Example refactor for testGetRequest
 public function testGetRequest(): void
 {
-    if ($this->adapter === null) {
-        $this->markTestSkipped('Swoole extension is not installed');
-    }
-
     $response = $this->adapter->send(

Apply the same pattern to all other test methods.

Also applies to: 24-28

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9cff1be and 501264b.

📒 Files selected for processing (7)
  • phpstan.neon
  • src/Adapter/Curl.php
  • src/Adapter/Swoole.php
  • tests/Adapter/CurlTest.php
  • tests/Adapter/SwooleTest.php
  • tests/ClientTest.php
  • tests/router.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/Adapter/CurlTest.php
🧰 Additional context used
🧬 Code graph analysis (2)
src/Adapter/Curl.php (4)
src/Chunk.php (1)
  • Chunk (12-67)
src/Exception.php (1)
  • Exception (7-21)
src/Response.php (1)
  • Response (12-115)
src/Adapter.php (1)
  • send (26-33)
tests/ClientTest.php (4)
src/Adapter/Curl.php (1)
  • Curl (17-108)
src/Adapter/Swoole.php (1)
  • Swoole (17-286)
src/Client.php (2)
  • Client (13-416)
  • fetch (282-335)
src/Response.php (2)
  • Response (12-115)
  • getStatusCode (72-75)
🪛 PHPMD (2.15.0)
src/Adapter/Curl.php

52-52: Avoid unused parameters such as '$curl'. (undefined)

(UnusedFormalParameter)

🔇 Additional comments (16)
tests/router.php (1)

172-173: LGTM!

Adding the Content-Type: application/json header for the default JSON response is correct and aligns with other response paths in this router that already set appropriate content types.

phpstan.neon (1)

5-8: LGTM!

The PHPStan configuration is correctly set up:

  • Including tests in analysis paths improves code quality coverage
  • Ignoring the Swoole coroutine function error is appropriate since Swoole is an optional extension
  • Setting reportUnmatchedIgnoredErrors: false prevents CI failures in environments where Swoole is installed
src/Adapter/Curl.php (3)

52-60: Unused $curl parameter is required by cURL callback signature.

The static analysis tool flagged the unused $curl parameter, but this is a false positive. The CURLOPT_HEADERFUNCTION callback signature requires ($ch, $header) as parameters per PHP documentation, even if $ch is not used in the callback body.


82-84: LGTM!

The conditional CURLOPT_POSTFIELDS setting correctly addresses the concern about GET requests. The body is only set when it's non-null, non-empty array, and non-empty string.


90-106: LGTM!

The execution and error handling logic is correct:

  • Uses CURLOPT_WRITEFUNCTION for body accumulation (no need for CURLOPT_RETURNTRANSFER)
  • Proper error handling with curl_error()
  • Resource cleanup guaranteed via finally block
src/Adapter/Swoole.php (3)

128-132: LGTM!

The addData parameter order has been corrected to ($value, $key), matching Swoole's method signature.


157-173: LGTM!

The variable shadowing issue has been fixed by using $currentResponseBody instead of overwriting the $body parameter.


267-274: LGTM!

The coroutine context handling correctly detects whether execution is already within a coroutine and either executes directly or creates a new scheduler as needed.

tests/ClientTest.php (5)

6-7: LGTM!

The imports for Curl and Swoole adapters are correctly added to support the new adapter tests.


14-20: LGTM!

The test correctly verifies that Client uses the Curl adapter by default and successfully makes a request.


25-31: LGTM!

The test correctly verifies that a custom adapter (explicitly Curl) can be injected into the Client.


36-46: LGTM!

The Swoole adapter test correctly:

  • Checks for Swoole availability before running
  • Marks the test as skipped when Swoole is not installed
  • Uses 127.0.0.1 instead of localhost (good practice for Swoole)

108-124: LGTM!

The content-type validation logic correctly handles:

  • Case-insensitive header lookup (Content-Type vs content-type)
  • Only validating when headers or body were provided
  • Stripping charset suffixes with explode(';', ...)[0]
  • Asserting against the expected content-type based on test input
tests/Adapter/SwooleTest.php (3)

44-48: Good test coverage for adapter functionality.

The tests comprehensively cover key adapter behaviors: basic requests, JSON body handling, redirect following, chunked responses, and response headers. The chunk callback test properly validates chunk properties including index, size, and data content.

Also applies to: 74-78, 131-134, 191-200


212-217: Array body handling is correctly supported with form-urlencoded encoding.

The Swoole adapter's send() method accepts mixed $body, allowing both array and string types. When an array is passed with content-type: application/x-www-form-urlencoded, the adapter automatically encodes it using http_build_query() before sending. The test code is correct.


31-31: No action required—URL format is intentional and functional.

Both CurlTest and SwooleTest intentionally use scheme-less URLs. The Swoole adapter automatically adds http:// when a scheme is missing (line 58-61 of Swoole.php: if (!preg_match('~^https?://~i', $url)) { $url = 'http://' . $url; }). The different hosts (localhost vs 127.0.0.1) reflect test environment requirements, not an inconsistency issue.

Likely an incorrect or invalid review comment.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

- Add Dockerfile using appwrite/base:0.10.4 which includes Swoole
- Add docker-compose.yml for running tests
- Update tests workflow to use Docker for full Swoole test coverage

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@abnegate abnegate requested a review from Meldiron January 15, 2026 13:11
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@Dockerfile`:
- Line 1: Update the Dockerfile FROM directive to use consistent keyword casing
by changing "FROM composer:2.0 as step0" to use uppercase AS ("FROM composer:2.0
AS step0"); this keeps the FROM/AS keywords consistent with the rest of the
Dockerfile (e.g., the other FROM ... AS occurrences).
- Around line 5-6: The Dockerfile currently tries to COPY ./composer.lock into
the image via the instruction COPY ./composer.lock /src/, but the file is
missing causing CI to fail; fix by either (A) removing the COPY ./composer.lock
/src/ line so the lock file is generated during build, or (B) generate and
commit a composer.lock locally (run composer install, then git add composer.lock
and git commit) so the existing COPY ./composer.lock /src/ succeeds — pick one
approach and update the Dockerfile or repository accordingly.
🧹 Nitpick comments (1)
.github/workflows/tests.yml (1)

17-28: Update GitHub Actions to latest versions.

The docker/setup-buildx-action@v2 and docker/build-push-action@v3 actions should be updated to v3 and v6 respectively to use the latest available versions and ensure compatibility with current runners.

Proposed fix
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v2
+        uses: docker/setup-buildx-action@v3

       - name: Build image
-        uses: docker/build-push-action@v3
+        uses: docker/build-push-action@v6
         with:
           context: .
           push: false
           tags: fetch-dev
           load: true
           cache-from: type=gha
           cache-to: type=gha,mode=max
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 501264b and dd8b595.

📒 Files selected for processing (3)
  • .github/workflows/tests.yml
  • Dockerfile
  • docker-compose.yml
🧰 Additional context used
🪛 actionlint (1.7.10)
.github/workflows/tests.yml

18-18: the runner of "docker/setup-buildx-action@v2" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)


21-21: the runner of "docker/build-push-action@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue

(action)

🪛 GitHub Actions: Tests
Dockerfile

[error] 6-6: failed to compute cache key: failed to calculate checksum of ref uv17ojquobf1ymfstoyvdg4od::i0qtsc29jddind921kf67rrfr: "/composer.lock": not found


[warning] 1-1: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 1)

🔇 Additional comments (2)
docker-compose.yml (1)

1-10: LGTM!

The docker-compose configuration correctly builds from the local Dockerfile, maps the appropriate port, and mounts source directories for development. The volume mounts appropriately override the copied files for live development workflows.

.github/workflows/tests.yml (1)

30-36: Test execution approach looks good.

The workflow correctly starts the containerized test environment and runs PHPUnit inside the container. The sleep 5 is acceptable for CI, though a health check could be more robust if startup issues arise.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

abnegate and others added 2 commits January 17, 2026 00:33
- Remove COPY ./composer.lock line since file is in .gitignore
- Use composer update instead of install (generates lock at build time)
- Fix FROM...AS casing inconsistency

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Curl: Store and reuse CurlHandle with curl_reset() between requests
- Swoole: Cache clients by host:port:ssl key for connection reuse
- Replace FQNs with proper use statements
- Extract configureBody() helper method to reduce duplication
- Enable keep_alive for Swoole clients
- Add __destruct() to properly close handles/clients

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/Adapter/Swoole.php`:
- Around line 287-291: PHPStan is reporting Swoole\Coroutine::run as missing
because the Swoole stubs aren't included; add the swoole/ide-helper package as a
dev dependency and configure phpstan to load its stubs so calls like
Coroutine::getCid() and run($executeRequest) are recognized. Specifically, add
swoole/ide-helper to composer require --dev, then add the ide-helper stubs.php
path to phpstan.neon under parameters->stubFiles (pointing to
vendor/swoole/ide-helper/stubs.php), and ensure the stubs version matches your
installed Swoole/OpenSwoole 4.x so Coroutine::run is declared.
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0a98001 and b5b427b.

📒 Files selected for processing (2)
  • src/Adapter/Curl.php
  • src/Adapter/Swoole.php
🧰 Additional context used
🧬 Code graph analysis (1)
src/Adapter/Curl.php (4)
src/Chunk.php (1)
  • Chunk (12-67)
src/Exception.php (1)
  • Exception (7-21)
src/Response.php (1)
  • Response (12-115)
src/Adapter.php (1)
  • send (26-33)
🪛 GitHub Check: CodeQL
src/Adapter/Swoole.php

[failure] 16-16:
Used function Swoole\Coroutine\run not found.

🪛 PHPMD (2.15.0)
src/Adapter/Curl.php

71-71: Avoid unused parameters such as '$curl'. (undefined)

(UnusedFormalParameter)

🪛 PHPStan (2.1.33)
src/Adapter/Swoole.php

16-16: Used function Swoole\Coroutine\run not found.
Learn more at https://phpstan.org/user-guide/discovering-symbols

(function.notFound)

🔇 Additional comments (9)
src/Adapter/Curl.php (3)

27-36: Handle reuse/reset looks solid.
Please confirm the minimum supported PHP version includes the CurlHandle type and the cURL extension is guaranteed in target runtimes.


50-121: Send path is clean and readable.
Please verify CURLOPT_CONNECTTIMEOUT_MS / CURLOPT_TIMEOUT_MS are available in the minimum supported cURL version.


127-133: Destructor cleanup is fine.
Closing the cached handle should prevent leaks; please confirm this aligns with your long‑lived process expectations.

src/Adapter/Swoole.php (6)

35-38: Availability check is straightforward.
Please confirm your support matrix (Swoole vs OpenSwoole) matches the class_exists target.


48-57: Client cache looks fine.
Keying by host/port/SSL is sensible; just ensure this reuse pattern matches Swoole client lifecycle guarantees.


67-75: Close/removal logic looks good.
No concerns here.


307-313: Destructor cleanup looks good.
Closing cached clients on teardown is appropriate.


85-126: Remove this concern — Swoole clears request body data automatically after each request.

The Swoole HTTP client does not retain request body data between requests. According to OpenSwoole documentation, "Any data which is set is not preserved once a request is made." When $body is null and the method returns early, there is no risk of body data leakage from prior requests. The proposed fix is unnecessary.

Likely an incorrect or invalid review comment.


196-205: Always reset headers when reusing cached client.
The Swoole HTTP client persists headers across requests when the connection is pooled/reused. Calling setHeaders([]) properly resets headers and is supported by Swoole/OpenSwoole. However, this fix is only necessary if the adapter actually pools/reuses client connections across requests. Verify that your implementation reuses clients; if so, apply the proposed fix to prevent credential leakage.

🩹 Proposed fix
-                if (!empty($allHeaders)) {
-                    $client->setHeaders($allHeaders);
-                }
+                // Always set headers to avoid leaking previous request headers
+                $client->setHeaders($allHeaders);

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

- Curl: Accept array of CURLOPT_* options in constructor
- Swoole: Accept array of client settings in constructor
- Config options are merged with defaults (user config takes precedence)
- Extracted buildClientSettings() helper in Swoole adapter

Example usage:
  // Curl with custom SSL options
  new Curl([CURLOPT_SSL_VERIFYPEER => false]);

  // Swoole with long connections
  new Swoole(['keep_alive' => true, 'timeout' => 60]);

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/Adapter/Curl.php`:
- Around line 42-48: The getHandle method in Curl.php assigns curl_init()
directly to $this->handle but curl_init() can return false in PHP 8.1+, causing
a TypeError when the method's return type is CurlHandle; change getHandle to
check the result of curl_init() and if it returns false throw a descriptive
RuntimeException (or custom exception) instead of assigning/returning false,
otherwise assign the CurlHandle to $this->handle and return it; ensure
references to curl_reset($this->handle) remain guarded by the handle being a
valid CurlHandle.

In `@src/Adapter/Swoole.php`:
- Around line 61-70: The getClient method currently returns a cached
SwooleClient from $this->clients which allows multiple coroutines to share the
same Swoole\Coroutine\Http\Client and causes unsafe concurrent use; change
getClient(SwooleClient) to always create and return a fresh SwooleClient
instance (new SwooleClient($host, $port, $ssl)) per call instead of using
$this->clients, or alternatively replace the cache with a proper pool
implementation (e.g., Swoole\ClientPool/ConnectionPool) and update callers to
checkout/return clients; update references to getClient and remove reliance on
$this->clients to ensure exclusive client usage per coroutine.
🧹 Nitpick comments (4)
src/Adapter/Curl.php (2)

86-94: Avoid mixing headers across redirects.

When CURLOPT_FOLLOWLOCATION is enabled, the header callback is invoked per response hop. Consider clearing $responseHeaders on each new status line so the final Response reflects the terminal hop only.

♻️ Suggested tweak
             CURLOPT_HEADERFUNCTION => function ($curl, $header) use (&$responseHeaders) {
                 $len = strlen($header);
+                if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+\d+/', $header) === 1) {
+                    $responseHeaders = [];
+                    return $len;
+                }
                 $header = explode(':', $header, 2);
                 if (count($header) < 2) {
                     return $len;
                 }

95-106: Streaming path returns an empty body.

When $chunkCallback is set, $responseBody stays empty. If callers still expect Response::getBody() to contain the full payload, consider optionally buffering (or explicitly documenting that body will be empty in streaming mode).

src/Adapter/Swoole.php (2)

98-102: Verify body reset on reused clients.
When $body === null, configureBody() returns without clearing prior payload. If Swoole retains previous request data on a reused client, this can leak the prior body into the next request. Please confirm behavior; if it persists, explicitly reset.

♻️ Possible fix (if Swoole retains prior data)
-        if ($body === null) {
-            return;
-        }
+        if ($body === null) {
+            $client->setData('');
+            return;
+        }

249-261: Consider keeping Response body when chunkCallback is used.
Right now, when $chunkCallback is set, $responseBody remains empty even though Swoole already buffers the full body. If callers still expect Response->text()/json() to work, consider assigning $responseBody regardless or documenting the behavior.

💡 Optional tweak for consistency
-                    if ($chunkCallback !== null && !empty($currentResponseBody)) {
+                    if ($chunkCallback !== null && $currentResponseBody !== '') {
                         $chunk = new Chunk(
                             data: $currentResponseBody,
                             size: strlen($currentResponseBody),
                             timestamp: microtime(true),
                             index: $chunkIndex++
                         );
                         $chunkCallback($chunk);
-                    } else {
-                        $responseBody = $currentResponseBody;
                     }
+                    $responseBody = $currentResponseBody;
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b5b427b and 018602c.

📒 Files selected for processing (2)
  • src/Adapter/Curl.php
  • src/Adapter/Swoole.php
🧰 Additional context used
🧬 Code graph analysis (2)
src/Adapter/Swoole.php (5)
src/Chunk.php (2)
  • Chunk (12-67)
  • __construct (20-26)
src/Exception.php (2)
  • Exception (7-21)
  • __construct (13-16)
src/Response.php (3)
  • Response (12-115)
  • __construct (40-48)
  • getStatusCode (72-75)
src/Adapter/Curl.php (2)
  • __construct (32-35)
  • send (65-140)
src/Adapter.php (1)
  • send (26-33)
src/Adapter/Curl.php (5)
src/Chunk.php (2)
  • Chunk (12-67)
  • __construct (20-26)
src/Exception.php (2)
  • Exception (7-21)
  • __construct (13-16)
src/Response.php (2)
  • Response (12-115)
  • __construct (40-48)
src/Client.php (1)
  • __construct (50-53)
src/Adapter.php (1)
  • send (26-33)
🪛 PHPMD (2.15.0)
src/Adapter/Curl.php

86-86: Avoid unused parameters such as '$curl'. (undefined)

(UnusedFormalParameter)

🔇 Additional comments (7)
src/Adapter/Curl.php (2)

32-35: Clean config injection.

Keeps adapter-level overrides isolated; looks good.


145-149: Destructor cleanup looks good.

Closing the cached handle avoids resource leaks.

src/Adapter/Swoole.php (5)

33-41: LGTM: clean config capture.


48-51: LGTM: lightweight availability check.


80-87: LGTM: cache cleanup is clear and safe.


148-158: LGTM: defaults merged cleanly with user config.


331-336: LGTM: destructor closes pooled clients.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

abnegate and others added 3 commits January 17, 2026 01:54
Swoole adapter:
- Add `coroutines` boolean constructor parameter (default: true)
- When true: automatically wraps requests in coroutine scheduler
- When false: executes directly (for use inside Swoole servers)
- Refactored request logic into executeRequest() method

Curl adapter:
- Fix getHandle() to properly handle curl_init() returning false
- Throw descriptive Exception instead of causing TypeError

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Both adapters now use discoverable named parameters instead of opaque arrays:

Curl adapter parameters:
- sslVerifyPeer, sslVerifyHost, sslCertificate, sslKey
- caInfo, caPath
- proxy, proxyUserPwd, proxyType
- httpVersion, tcpKeepAlive, tcpKeepIdle, tcpKeepInterval
- bufferSize, verbose

Swoole adapter parameters:
- coroutines, keepAlive, socketBufferSize, httpCompression
- sslVerifyPeer, sslHostName, sslCafile, sslAllowSelfSigned
- packageMaxLength, websocketMask, websocketCompression
- bindAddress, bindPort, lowaterMark

All parameters have sensible defaults, enabling IDE autocompletion
and discoverability without requiring documentation lookup.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@abnegate abnegate force-pushed the feat-adapters branch 2 times, most recently from 5d11230 to 3c51616 Compare January 16, 2026 13:12
- coroutines=true: Uses Swoole\Coroutine\Http\Client (default)
- coroutines=false: Uses Swoole\Http\Client (sync/blocking)

Updated type hints to support both client types via union types.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Import Swoole\Http\Client as SyncClient
- Remove @phpstan-ignore annotations
- Use @var CoClient annotation for type compatibility

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/Adapter/Swoole.php`:
- Around line 317-339: The redirect handling reuses $allHeaders across origin
changes and may leak sensitive headers; update the redirect branch in Swoole.php
where $newHost/$newPort/$newSsl are compared to $host/$port/$ssl (inside the
block that calls $this->getClient, $client->set, $client->setMethod, and
$client->setHeaders) to filter out sensitive headers before calling
$client->setHeaders: remove Authorization, Cookie, Proxy-Authorization and Host
on cross-origin redirects (i.e. when $newHost !== $host || $newPort !== $port ||
$newSsl !== $ssl), and if you need a Host header for the new request set it
explicitly to $newHost (or omit it to let the client set it), then pass the
sanitized headers to $client->setHeaders so cookies and auth are not leaked to
the new origin.
- Around line 57-75: The constructor currently defaults to coroutines=true which
leads to a fatal error if the CoClient class isn't present; update the
constructor (or getClient) to detect at instantiation whether the coroutine
client class (Swoole\Coroutine\Http\Client or CoClient) exists and if not set
$this->coroutines = false (or otherwise fall back) so that getClient() and
send() will create the sync Swoole\Http\Client instead; ensure the logic
references __construct, isAvailable, getClient, send, CoClient and
Swoole\Http\Client so the adapter auto-falls back to the sync client when
coroutine client classes are unavailable.
- Around line 120-164: getClient() currently declares return type CoClient but
may instantiate Swoole\Http\Client when $this->coroutines is false; likewise
configureBody() type-hints CoClient but can receive Swoole\Http\Client. Fix by
changing the signatures and PHPDoc to use a union type
CoClient|Swoole\Http\Client for getClient()'s return and for configureBody()'s
$client parameter (and update any related PHPDoc annotations), ensuring both
concrete client types are accepted by methods that use them.
♻️ Duplicate comments (2)
src/Adapter/Swoole.php (2)

124-134: Avoid sharing a cached coroutine client across concurrent coroutines.

The host-level cache returns the same Swoole\Coroutine\Http\Client for multiple concurrent requests, which can interleave state and break requests. Prefer a per-request client or a coroutine-aware pool.


8-10: Static analysis: add Swoole stubs so Swoole\Http\Client resolves in CI.

PHPStan/CodeQL report Swoole\Http\Client as missing. Add the Swoole IDE stubs in dev and include them in phpstan config.

#!/bin/bash
# Inspect phpstan config for stubFiles/scanFiles entries
fd -a -g 'phpstan*.neon*' -x sed -n '1,200p' {}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3647dd3 and 38aeb2d.

📒 Files selected for processing (1)
  • src/Adapter/Swoole.php
🧰 Additional context used
🧬 Code graph analysis (1)
src/Adapter/Swoole.php (4)
src/Chunk.php (2)
  • Chunk (12-67)
  • __construct (20-26)
src/Exception.php (2)
  • Exception (7-21)
  • __construct (13-16)
src/Response.php (3)
  • Response (12-115)
  • __construct (40-48)
  • getStatusCode (72-75)
src/Adapter.php (1)
  • send (26-33)
🪛 GitHub Check: CodeQL
src/Adapter/Swoole.php

[failure] 129-129:
Instantiated class Swoole\Http\Client not found.

🪛 PHPStan (2.1.33)
src/Adapter/Swoole.php

129-129: Instantiated class Swoole\Http\Client not found.
Learn more at https://phpstan.org/user-guide/discovering-symbols

(class.notFound)

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 120 to 164
private function getClient(string $host, int $port, bool $ssl): CoClient
{
$key = "{$host}:{$port}:" . ($ssl ? '1' : '0');

if (!isset($this->clients[$key])) {
if ($this->coroutines) {
$this->clients[$key] = new CoClient($host, $port, $ssl);
} else {
/** @var CoClient $client */
$client = new SyncClient($host, $port, $ssl);
$this->clients[$key] = $client;
}
}

return $this->clients[$key];
}

/**
* Close and remove a client from the cache
*
* @param string $host
* @param int $port
* @param bool $ssl
* @return void
*/
private function closeClient(string $host, int $port, bool $ssl): void
{
$key = "{$host}:{$port}:" . ($ssl ? '1' : '0');

if (isset($this->clients[$key])) {
$this->clients[$key]->close();
unset($this->clients[$key]);
}
}

/**
* Configure body data on the client
*
* @param CoClient $client Swoole HTTP client
* @param mixed $body
* @param array<string, string> $headers
* @return void
*/
private function configureBody(CoClient $client, mixed $body, array $headers): void
{
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cd /repo && find . -name "composer.json" -type f | head -5

Repository: utopia-php/fetch

Length of output: 115


🏁 Script executed:

cd /repo && cat composer.json | grep -A 5 '"php"'

Repository: utopia-php/fetch

Length of output: 115


🏁 Script executed:

cd /repo && wc -l src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 115


🏁 Script executed:

cd /repo && cat -n src/Adapter/Swoole.php | head -50

Repository: utopia-php/fetch

Length of output: 115


🏁 Script executed:

find . -name "composer.json" -type f

Repository: utopia-php/fetch

Length of output: 1401


🏁 Script executed:

cat composer.json | grep -A 5 '"php"'

Repository: utopia-php/fetch

Length of output: 214


🏁 Script executed:

wc -l src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 86


🏁 Script executed:

head -70 src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 2327


🏁 Script executed:

sed -n '1,150p' src/Adapter/Swoole.php | tail -80

Repository: utopia-php/fetch

Length of output: 2500


🏁 Script executed:

sed -n '100,220p' src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 3777


🏁 Script executed:

grep -n "class SyncClient\|use.*SyncClient" src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 42


🏁 Script executed:

find . -name "*.php" -type f | xargs grep -l "class SyncClient"

Repository: utopia-php/fetch

Length of output: 44


🏁 Script executed:

grep -n "SyncClient\|Swoole.Http.Client" src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 327


🏁 Script executed:

head -15 src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 328


🏁 Script executed:

sed -n '145,165p' src/Adapter/Swoole.php

Repository: utopia-php/fetch

Length of output: 670


Fix type mismatch: getClient() returns Swoole\Http\Client but declares return type CoClient.

When $coroutines is false, getClient() instantiates Swoole\Http\Client via dynamic class loading (line 128), but the method signature declares return type CoClient (line 118). This causes a type mismatch that will fail strict type checking. The configureBody() method also type-hints CoClient $client but receives instances of Swoole\Http\Client. Use a union type to correctly declare the actual return type. The project requires PHP >=8.1, which supports union types.

Proposed fix
-    /**
-     * `@var` array<string, CoClient>
-     */
+    /**
+     * `@var` array<string, CoClient|Swoole\Http\Client>
+     */
     private array $clients = [];

-    private function getClient(string $host, int $port, bool $ssl): CoClient
+    private function getClient(string $host, int $port, bool $ssl): CoClient|Swoole\Http\Client

-    private function configureBody(CoClient $client, mixed $body, array $headers): void
+    private function configureBody(CoClient|Swoole\Http\Client $client, mixed $body, array $headers): void
🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 129-129:
Instantiated class Swoole\Http\Client not found.

🪛 PHPStan (2.1.33)

129-129: Instantiated class Swoole\Http\Client not found.
Learn more at https://phpstan.org/user-guide/discovering-symbols

(class.notFound)

🤖 Prompt for AI Agents
In `@src/Adapter/Swoole.php` around lines 120 - 164, getClient() currently
declares return type CoClient but may instantiate Swoole\Http\Client when
$this->coroutines is false; likewise configureBody() type-hints CoClient but can
receive Swoole\Http\Client. Fix by changing the signatures and PHPDoc to use a
union type CoClient|Swoole\Http\Client for getClient()'s return and for
configureBody()'s $client parameter (and update any related PHPDoc annotations),
ensuring both concrete client types are accepted by methods that use them.

Comment on lines +317 to +339
if ($allowRedirects && in_array($statusCode, [301, 302, 303, 307, 308]) && $redirectCount < $maxRedirects) {
$location = $client->headers['location'] ?? $client->headers['Location'] ?? null;
if ($location !== null) {
$redirectCount++;
if (strpos($location, 'http') === 0) {
$parsedLocation = parse_url($location);
$newHost = $parsedLocation['host'] ?? $host;
$newPort = $parsedLocation['port'] ?? (isset($parsedLocation['scheme']) && $parsedLocation['scheme'] === 'https' ? 443 : 80);
$newSsl = ($parsedLocation['scheme'] ?? 'http') === 'https';
$path = ($parsedLocation['path'] ?? '/') . (isset($parsedLocation['query']) ? '?' . $parsedLocation['query'] : '');

if ($newHost !== $host || $newPort !== $port || $newSsl !== $ssl) {
$host = $newHost;
$port = $newPort;
$ssl = $newSsl;
$client = $this->getClient($host, $port, $ssl);
$client->set($this->buildClientSettings($timeout, $connectTimeout));
$client->setMethod($method);
if (!empty($allHeaders)) {
$client->setHeaders($allHeaders);
}
$this->configureBody($client, $body, $headers);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Strip sensitive headers on cross-origin redirects.

When the redirect changes host/port/SSL, reusing $allHeaders can leak Authorization/Cookie/Proxy-Authorization to a different origin. Drop sensitive headers (and Host) on cross-origin redirects.

🔒 Proposed fix
                         if ($newHost !== $host || $newPort !== $port || $newSsl !== $ssl) {
                             $host = $newHost;
                             $port = $newPort;
                             $ssl = $newSsl;
+                            $allHeaders = array_filter(
+                                $allHeaders,
+                                fn($v, $k) => !in_array(strtolower($k), ['authorization', 'proxy-authorization', 'cookie', 'host'], true),
+                                ARRAY_FILTER_USE_BOTH
+                            );
                             $client = $this->getClient($host, $port, $ssl);
🤖 Prompt for AI Agents
In `@src/Adapter/Swoole.php` around lines 317 - 339, The redirect handling reuses
$allHeaders across origin changes and may leak sensitive headers; update the
redirect branch in Swoole.php where $newHost/$newPort/$newSsl are compared to
$host/$port/$ssl (inside the block that calls $this->getClient, $client->set,
$client->setMethod, and $client->setHeaders) to filter out sensitive headers
before calling $client->setHeaders: remove Authorization, Cookie,
Proxy-Authorization and Host on cross-origin redirects (i.e. when $newHost !==
$host || $newPort !== $port || $newSsl !== $ssl), and if you need a Host header
for the new request set it explicitly to $newHost (or omit it to let the client
set it), then pass the sanitized headers to $client->setHeaders so cookies and
auth are not leaked to the new origin.

abnegate and others added 2 commits January 17, 2026 03:00
- Import Swoole\Http\Client as SyncClient
- Use SyncClient::class instead of string names
- Add PHPStan ignore pattern for missing Swoole\Http\Client stubs

Co-Authored-By: Claude Opus 4.5 <[email protected]>
The Swoole extension isn't installed in CI for static analysis,
causing PHPStan to not find Swoole classes. The ide-helper package
provides stubs so PHPStan can analyze the code properly.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/Adapter/Swoole.php`:
- Around line 192-199: The check for 'content-type' being set is case-sensitive
and can miss headers like 'Content-Type'; inside the Swoole adapter's send logic
where $headers is inspected (near variables $hasFiles, $formData and
client->setData calls), normalize header keys to lowercase (or perform a
case-insensitive lookup) before checking content-type, matching the
normalization used for response headers in this file; update the code that
branches on application/x-www-form-urlencoded to use the normalized header key
(e.g., lowercased $headers['content-type']) so the urlencoded path is reliably
detected when calling send().
♻️ Duplicate comments (4)
src/Adapter/Swoole.php (4)

57-74: Auto-disable coroutine mode when the coroutine client isn’t available.

Line 73 sets $this->coroutines directly. If only Swoole\Http\Client exists, send() will still try CoClient, causing a fatal error. Guard here (or in getClient()) so the adapter gracefully falls back to sync mode.

✅ Suggested fix
-        $this->coroutines = $coroutines;
+        $this->coroutines = $coroutines && class_exists(CoClient::class);

120-164: Fix return/parameter types for sync client mode (TypeError risk).

Line 120 promises CoClient, but Line 129 constructs SyncClient when coroutines are off. With strict_types=1, this will throw. configureBody() also type-hints CoClient only. Use a union type (PHP ≥ 8.1) and update the cache docblock to reflect both types.

✅ Suggested fix
-    /**
-     * `@var` array<string, CoClient>
-     */
+    /**
+     * `@var` array<string, CoClient|SyncClient>
+     */
     private array $clients = [];

-    private function getClient(string $host, int $port, bool $ssl): CoClient
+    private function getClient(string $host, int $port, bool $ssl): CoClient|SyncClient

-    private function configureBody(CoClient $client, mixed $body, array $headers): void
+    private function configureBody(CoClient|SyncClient $client, mixed $body, array $headers): void

122-134: Avoid sharing one client across concurrent coroutines.

The per-host cache returns a single client instance (Line 124+). Concurrent send() calls can interleave on the same Swoole\Coroutine\Http\Client, which is not safe for concurrent use. Prefer a client-per-request model or a coroutine-safe pool with exclusive checkout.


328-337: Strip sensitive headers on cross-origin redirects.

When host/port/SSL changes, $allHeaders is reused as-is (Line 335), which can leak Authorization, Cookie, Proxy-Authorization, or stale Host to a new origin. Filter them before setHeaders() in that branch.

✅ Suggested fix
                         if ($newHost !== $host || $newPort !== $port || $newSsl !== $ssl) {
                             $host = $newHost;
                             $port = $newPort;
                             $ssl = $newSsl;
+                            $allHeaders = array_filter(
+                                $allHeaders,
+                                fn($v, $k) => !in_array(strtolower($k), ['authorization', 'proxy-authorization', 'cookie', 'host'], true),
+                                ARRAY_FILTER_USE_BOTH
+                            );
                             $client = $this->getClient($host, $port, $ssl);
                             $client->set($this->buildClientSettings($timeout, $connectTimeout));
                             $client->setMethod($method);
                             if (!empty($allHeaders)) {
                                 $client->setHeaders($allHeaders);
                             }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38aeb2d and 9fe5a7a.

📒 Files selected for processing (2)
  • phpstan.neon
  • src/Adapter/Swoole.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • phpstan.neon

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

abnegate and others added 3 commits January 17, 2026 04:13
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/Client.php`:
- Around line 304-306: The code chooses $separator before trimming $url which
yields invalid URLs like http://example.com&foo=bar when $url ends with '?'; fix
this in the block that builds the query by first trimming trailing '?' and '&'
from $url (use rtrim($url, '?&') and assign back to $url or a new $baseUrl),
then determine $separator with str_contains($url, '?') ? '&' : '?', and finally
set $url = $url . $separator . http_build_query($query); reference the $url and
$query variables in Client.php to locate and update this logic.
♻️ Duplicate comments (4)
src/Adapter/Swoole.php (4)

133-152: Type mismatch: return type declares CoClient but may return Swoole\Http\Client.

When $this->coroutines is false, the method instantiates a sync client but the return type is declared as CoClient. This causes static analysis failures and misrepresents the actual behavior.

Suggested fix - use union return type
-    private function getClient(string $host, int $port, bool $ssl): CoClient
+    private function getClient(string $host, int $port, bool $ssl): CoClient|\Swoole\Http\Client
     {
         $key = "{$host}:{$port}:" . ($ssl ? '1' : '0');

         if (!isset($this->clients[$key])) {
             if ($this->coroutines) {
                 $this->clients[$key] = new CoClient($host, $port, $ssl);
             } else {
                 $syncClientClass = self::getSyncClientClass();
                 if (!class_exists($syncClientClass)) {
                     throw new Exception('Swoole sync HTTP client is not available');
                 }
-                /** `@var` CoClient $client */
                 $client = new $syncClientClass($host, $port, $ssl);
                 $this->clients[$key] = $client;
             }
         }

         return $this->clients[$key];
     }

180-221: Type hint should accept both client types.

The $client parameter is typed as CoClient but may receive a sync Swoole\Http\Client instance. Additionally, the header check on line 213 is case-sensitive, which may miss Content-Type or other casing variants.

Suggested fix
-    private function configureBody(CoClient $client, mixed $body, array $headers): void
+    private function configureBody(CoClient|\Swoole\Http\Client $client, mixed $body, array $headers): void
     {
         if ($body === null) {
             return;
         }
+        
+        $normalizedHeaders = array_change_key_case($headers, CASE_LOWER);

         if (is_array($body)) {
             $hasFiles = false;
             $formData = [];
             // ... file handling ...

             if ($hasFiles) {
                 foreach ($formData as $key => $value) {
                     $client->addData($value, $key);
                 }
-            } elseif (isset($headers['content-type']) && $headers['content-type'] === 'application/x-www-form-urlencoded') {
+            } elseif (($normalizedHeaders['content-type'] ?? '') === 'application/x-www-form-urlencoded') {
                 $client->setData(http_build_query($body));
             } else {
                 $client->setData($body);
             }

345-356: Security: sensitive headers are not stripped on cross-origin redirects.

When a redirect changes the host, port, or SSL setting, the code reuses $allHeaders which may contain Authorization, Cookie, or Proxy-Authorization headers. These credentials could be leaked to a different origin.

Proposed fix - strip sensitive headers on cross-origin redirects
                         if ($newHost !== $host || $newPort !== $port || $newSsl !== $ssl) {
                             $host = $newHost;
                             $port = $newPort;
                             $ssl = $newSsl;
+                            // Strip sensitive headers on cross-origin redirects
+                            $sensitiveHeaders = ['authorization', 'proxy-authorization', 'cookie', 'host'];
+                            $allHeaders = array_filter(
+                                $allHeaders,
+                                fn($key) => !in_array(strtolower($key), $sensitiveHeaders, true),
+                                ARRAY_FILTER_USE_KEY
+                            );
                             $client = $this->getClient($host, $port, $ssl);
                             $client->set($this->buildClientSettings($timeout, $connectTimeout));
                             $client->setMethod($method);

133-152: Concurrent coroutines may share the same client unsafely.

The getClient() method caches and returns the same CoClient instance per host/port/ssl. When multiple coroutines call send() concurrently, they will share this client instance, which violates Swoole's concurrency model where clients should not be reused across concurrent coroutines. This can cause request interleaving and failures.

Consider either creating a new client per request or implementing a connection pool with proper checkout/return semantics.

🧹 Nitpick comments (2)
src/Adapter/Swoole.php (2)

23-26: PHPDoc type annotation is inaccurate.

The $clients array can store both CoClient (coroutine) and Swoole\Http\Client (sync) instances depending on the $coroutines flag, but the PHPDoc only declares CoClient. This causes static analysis tools to report type mismatches.

Suggested fix
     /**
-     * `@var` array<string, CoClient>
+     * `@var` array<string, CoClient|\Swoole\Http\Client>
      */
     private array $clients = [];

420-422: Exception re-throw loses original stack trace.

Wrapping the caught exception in a new Exception discards the original stack trace, which can make debugging more difficult. Consider passing the original exception as the previous parameter.

Suggested improvement
         if ($exception !== null) {
-            throw new Exception($exception->getMessage());
+            throw new Exception($exception->getMessage(), 0, $exception);
         }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0ef30e8 and 7763987.

📒 Files selected for processing (8)
  • phpstan.neon
  • src/Adapter/Swoole.php
  • src/Client.php
  • src/Response.php
  • tests/Adapter/CurlTest.php
  • tests/Adapter/SwooleTest.php
  • tests/ClientTest.php
  • tests/router.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/Adapter/SwooleTest.php
  • tests/Adapter/CurlTest.php
🧰 Additional context used
🧬 Code graph analysis (2)
src/Response.php (1)
src/Exception.php (1)
  • Exception (7-21)
src/Adapter/Swoole.php (4)
src/Chunk.php (2)
  • Chunk (12-67)
  • __construct (20-26)
src/Exception.php (2)
  • Exception (7-21)
  • __construct (13-16)
src/Response.php (3)
  • Response (12-129)
  • __construct (40-48)
  • getStatusCode (72-75)
src/Adapter.php (1)
  • send (26-33)
🔇 Additional comments (23)
tests/router.php (3)

26-27: Defensive JSON decode for state loading is solid.

This avoids propagating null on invalid JSON.


45-47: Redirect host normalization is consistent with test URLs.


173-173: Explicit JSON Content-Type for the default response is good.

src/Client.php (4)

3-53: Adapter injection + default Curl wiring look good.

Please confirm the new 5s default connect timeout is acceptable for existing consumers.


171-173: JSON encode flag aggregation looks correct.


295-301: GraphQL body guardrail is a good safety check.


309-331: Adapter send delegation is clear and consistent.

src/Response.php (3)

83-95: text() now handles null/scalar bodies safely.


105-111: json() error handling is clear and explicit.


122-127: blob() byte-wise conversion aligns with updated expectations.

tests/ClientTest.php (7)

6-46: Adapter coverage tests are a nice addition.

Please verify CI runs both with and without the Swoole extension so the skip guard is exercised.


85-127: Stricter response-shape assertions improve signal.


138-181: File upload assertions are clearer and more robust.


194-247: Host normalization to 127.0.0.1:8000 looks consistent.


327-389: Data provider host updates are consistent.


433-487: Retry tests now align with the updated host.


494-626: Chunk handling tests and JSON guard look good.

phpstan.neon (1)

2-9: Configuration correctly uses dev dependency.

swoole/ide-helper is properly configured as a require-dev dependency and phpstan.neon correctly references its paths. PHPStan analysis will work as expected when dev dependencies are installed.

src/Adapter/Swoole.php (5)

56-103: LGTM!

The constructor correctly handles the fallback when CoClient is unavailable, and the configuration options are well-documented and properly initialized.


105-123: LGTM!

The isAvailable() check covers both client types, and the string concatenation in getSyncClientClass() is a reasonable approach to avoid autoload issues.


162-170: LGTM!

The closeClient method correctly closes the connection and removes the client from the cache.


230-236: LGTM!

The method cleanly merges the base configuration with request-specific timeout settings.


434-440: LGTM!

The destructor properly cleans up all cached client connections.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 304 to +306
if ($query) {
$url = rtrim($url, '?') . '?' . http_build_query($query);
$separator = str_contains($url, '?') ? '&' : '?';
$url = rtrim($url, '?&') . $separator . http_build_query($query);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid invalid URL when input ends with ?.

Because the separator is chosen before trimming, a URL like http://example.com? can become http://example.com&foo=bar. Consider trimming first.

🔧 Suggested fix
-            $separator = str_contains($url, '?') ? '&' : '?';
-            $url = rtrim($url, '?&') . $separator . http_build_query($query);
+            $trimmedUrl = rtrim($url, '?&');
+            $separator = str_contains($trimmedUrl, '?') ? '&' : '?';
+            $url = $trimmedUrl . $separator . http_build_query($query);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($query) {
$url = rtrim($url, '?') . '?' . http_build_query($query);
$separator = str_contains($url, '?') ? '&' : '?';
$url = rtrim($url, '?&') . $separator . http_build_query($query);
if ($query) {
$trimmedUrl = rtrim($url, '?&');
$separator = str_contains($trimmedUrl, '?') ? '&' : '?';
$url = $trimmedUrl . $separator . http_build_query($query);
🤖 Prompt for AI Agents
In `@src/Client.php` around lines 304 - 306, The code chooses $separator before
trimming $url which yields invalid URLs like http://example.com&foo=bar when
$url ends with '?'; fix this in the block that builds the query by first
trimming trailing '?' and '&' from $url (use rtrim($url, '?&') and assign back
to $url or a new $baseUrl), then determine $separator with str_contains($url,
'?') ? '&' : '?', and finally set $url = $url . $separator .
http_build_query($query); reference the $url and $query variables in Client.php
to locate and update this logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants