From f5a3e2d53a9d920fa33748470f33a8d27f71f02e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 13:57:41 +0530 Subject: [PATCH 1/8] (feat): replace raw curl transport with utopia-php/client Adapter::request() now builds PSR-7 requests via utopia-php/psr7's Request\Factory and sends them through the client's cURL adapter. Adapter::requestMulti() fans out over Swoole coroutines with a bounded Utopia\Client\Pool instead of curl_multi + HTTP/2 multiplexing. Both keep the legacy result array shape so adapters and tests are unaffected. Also: PHP >= 8.5 and ext-swoole required, Mailgun attachments use multipart Part::file, unused Adapter::getCountryCode() removed along with the libphonenumber dependency, phpstan upgraded to 2.x, and the test image switched to appwrite/utopia-base (ships Swoole, no io_uring). --- Dockerfile | 2 +- composer.json | 18 +- composer.lock | 506 +++++++++++------- phpstan.neon | 7 + src/Utopia/Messaging/Adapter.php | 312 ++++++----- .../Messaging/Adapter/Email/Mailgun.php | 6 +- src/Utopia/Messaging/Adapter/SMS/GEOSMS.php | 1 - src/Utopia/Messaging/Helpers/JWT.php | 2 +- src/Utopia/Messaging/Messages/SMS.php | 6 +- tests/Messaging/Adapter/Chat/DiscordTest.php | 2 +- tests/Messaging/Adapter/SMS/GEOSMSTest.php | 1 - tests/Messaging/Adapter/SMS/Msg91Test.php | 1 - tests/Messaging/Adapter/SMS/SMSTest.php | 2 - 13 files changed, 517 insertions(+), 349 deletions(-) create mode 100644 phpstan.neon diff --git a/Dockerfile b/Dockerfile index 5bc1e5e3..f236eff1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN composer install \ --no-scripts \ --prefer-dist -FROM php:8.3.11-cli-alpine3.20 +FROM appwrite/utopia-base:php-8.5-2.1.0 WORKDIR /usr/local/src/ diff --git a/composer.json b/composer.json index 261c6cc1..845c3c00 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "test": "./vendor/bin/phpunit", "lint": "./vendor/bin/pint --preset psr12 --test", "format": "./vendor/bin/pint --preset psr12", - "analyse": "./vendor/bin/phpstan analyse --memory-limit=2G --level=6 src tests" + "analyse": "./vendor/bin/phpstan analyse --memory-limit=2G" }, "autoload": { "psr-4": { @@ -22,21 +22,23 @@ } }, "require": { - "php": ">=8.1.0", + "php": ">=8.5.0", "ext-curl": "*", "ext-openssl": "*", - "phpmailer/phpmailer": "6.9.1", - "giggsey/libphonenumber-for-php-lite": "9.0.23", + "ext-swoole": "*", + "phpmailer/phpmailer": "^6.9", + "utopia-php/client": "^0.2", "utopia-php/telemetry": "^0.4" }, "require-dev": { - "phpunit/phpunit": "11.*", - "laravel/pint": "1.*", - "phpstan/phpstan": "1.*" + "phpunit/phpunit": "^11.0", + "laravel/pint": "^1.0", + "phpstan/phpstan": "^2.0", + "swoole/ide-helper": "^6.0" }, "config": { "platform": { - "php": "8.3" + "php": "8.5" }, "allow-plugins": { "php-http/discovery": true, diff --git a/composer.lock b/composer.lock index ba1f9046..72fc944c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,26 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "94123f2a566774952009659b7e6f241f", + "content-hash": "a9994addcba6c87fd84f4222054d804d", "packages": [ { "name": "brick/math", - "version": "0.14.8", + "version": "0.18.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", - "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.2", "phpstan/phpstan": "2.1.22", "phpunit/phpunit": "^11.5" }, @@ -56,7 +55,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.8" + "source": "https://github.com/brick/math/tree/0.18.0" }, "funding": [ { @@ -64,7 +63,7 @@ "type": "github" } ], - "time": "2026-02-10T14:33:43+00:00" + "time": "2026-06-14T18:21:03+00:00" }, { "name": "composer/semver", @@ -143,84 +142,6 @@ ], "time": "2025-08-20T19:15:30+00:00" }, - { - "name": "giggsey/libphonenumber-for-php-lite", - "version": "9.0.23", - "source": { - "type": "git", - "url": "https://github.com/giggsey/libphonenumber-for-php-lite.git", - "reference": "e8f6f896c1bef398136849934a934904bd9bf072" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/e8f6f896c1bef398136849934a934904bd9bf072", - "reference": "e8f6f896c1bef398136849934a934904bd9bf072", - "shasum": "" - }, - "require": { - "php": "^8.1", - "symfony/polyfill-mbstring": "^1.17" - }, - "conflict": { - "giggsey/libphonenumber-for-php": "*" - }, - "require-dev": { - "ext-dom": "*", - "friendsofphp/php-cs-fixer": "^3.71", - "infection/infection": "^0.29|^0.31.0", - "nette/php-generator": "^4.1", - "php-coveralls/php-coveralls": "^2.7", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.7", - "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpstan/phpstan-phpunit": "^2.0.4", - "phpstan/phpstan-strict-rules": "^2.0.3", - "phpunit/phpunit": "^10.5.45", - "symfony/console": "^6.4", - "symfony/filesystem": "^6.4", - "symfony/process": "^6.4" - }, - "suggest": { - "giggsey/libphonenumber-for-php": "Use libphonenumber-for-php for geocoding, carriers, timezones and matching" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "9.x-dev" - } - }, - "autoload": { - "psr-4": { - "libphonenumber\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Joshua Gigg", - "email": "giggsey@gmail.com", - "homepage": "https://giggsey.com/" - } - ], - "description": "A lite version of giggsey/libphonenumber-for-php, which is a PHP Port of Google's libphonenumber", - "homepage": "https://github.com/giggsey/libphonenumber-for-php-lite", - "keywords": [ - "geocoding", - "geolocation", - "libphonenumber", - "mobile", - "phonenumber", - "validation" - ], - "support": { - "issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues", - "source": "https://github.com/giggsey/libphonenumber-for-php-lite" - }, - "time": "2026-01-30T12:33:03+00:00" - }, { "name": "google/protobuf", "version": "v4.33.6", @@ -900,16 +821,16 @@ }, { "name": "phpmailer/phpmailer", - "version": "v6.9.1", + "version": "v6.12.0", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "039de174cd9c17a8389754d3b877a2ed22743e18" + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/039de174cd9c17a8389754d3b877a2ed22743e18", - "reference": "039de174cd9c17a8389754d3b877a2ed22743e18", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", "shasum": "" }, "require": { @@ -969,7 +890,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.1" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" }, "funding": [ { @@ -977,7 +898,7 @@ "type": "github" } ], - "time": "2023-11-25T22:23:28+00:00" + "time": "2025-10-15T16:49:08+00:00" }, { "name": "psr/container", @@ -1320,20 +1241,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -1392,22 +1313,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -1445,7 +1366,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1465,20 +1386,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.13", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", - "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "url": "https://api.github.com/repos/symfony/http-client/zipball/f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", + "reference": "f6bc6b5a54ff5afac4725cacec9bf2f52eb15920", "shasum": "" }, "require": { @@ -1546,7 +1467,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.13" + "source": "https://github.com/symfony/http-client/tree/v7.4.14" }, "funding": [ { @@ -1566,20 +1487,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T09:57:54+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", - "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", "shasum": "" }, "require": { @@ -1628,7 +1549,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" }, "funding": [ { @@ -1648,7 +1569,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:17:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -1897,16 +1818,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -1960,7 +1881,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -1980,7 +1901,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "tbachert/spi", @@ -2034,22 +1955,217 @@ }, "time": "2025-06-29T15:42:06+00:00" }, + { + "name": "utopia-php/client", + "version": "0.2.3", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/client.git", + "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/client/zipball/377dc1f79441ac1584f25dba57904ee1cf0f626b", + "reference": "377dc1f79441ac1584f25dba57904ee1cf0f626b", + "shasum": "" + }, + "require": { + "php": ">=8.5", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "utopia-php/pools": "^1.0", + "utopia-php/psr7": "^0.2", + "utopia-php/span": "^1.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "swoole/ide-helper": "^6.0" + }, + "suggest": { + "ext-curl": "Required to use the cURL HTTP client adapter.", + "ext-simplexml": "Required to decode XML responses with Response::xml().", + "ext-swoole": "Required to use the Swoole coroutine HTTP client adapter." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", + "keywords": [ + "client", + "curl", + "http", + "php", + "psr-18", + "swoole", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/client/issues", + "source": "https://github.com/utopia-php/client/tree/0.2.3" + }, + "time": "2026-07-13T15:29:09+00:00" + }, + { + "name": "utopia-php/pools", + "version": "1.0.9", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/pools.git", + "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/pools/zipball/13346168ca7097d9b220872a017c9ad8c5dac62f", + "reference": "13346168ca7097d9b220872a017c9ad8c5dac62f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "utopia-php/telemetry": "^0.4" + }, + "require-dev": { + "swoole/ide-helper": "6.*" + }, + "suggest": { + "ext-swoole": "Required to use the Swoole pool adapter" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Pools\\": "src/Pools" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "description": "A simple library to manage connection pools", + "keywords": [ + "framework", + "php", + "pools", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/pools/issues", + "source": "https://github.com/utopia-php/pools/tree/1.0.9" + }, + "time": "2026-06-26T10:48:18+00:00" + }, + { + "name": "utopia-php/psr7", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/psr7.git", + "reference": "115753c36194d53abe5587d383810724ac3a782d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/psr7/zipball/115753c36194d53abe5587d383810724ac3a782d", + "reference": "115753c36194d53abe5587d383810724ac3a782d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-simplexml": "Required to decode XML responses with Response::xml()." + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Psr7\\": "src/Psr7/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PSR-7 HTTP message implementations and PSR-17 factories for Utopia", + "keywords": [ + "http", + "php", + "psr-17", + "psr-7", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/psr7/issues", + "source": "https://github.com/utopia-php/psr7/tree/0.2.0" + }, + "time": "2026-07-06T12:40:23+00:00" + }, + { + "name": "utopia-php/span", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/span.git", + "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/span/zipball/d11f2714324cb22b286b2afbf3ea9de32c68de83", + "reference": "d11f2714324cb22b286b2afbf3ea9de32c68de83", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "swoole/ide-helper": "^5.0" + }, + "suggest": { + "ext-swoole": "Required for coroutine-based storage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Span\\": "src/Span/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple span tracing library for PHP with coroutine support", + "support": { + "issues": "https://github.com/utopia-php/span/issues", + "source": "https://github.com/utopia-php/span/tree/4.0.1" + }, + "time": "2026-06-20T09:45:06+00:00" + }, { "name": "utopia-php/telemetry", - "version": "0.4.1", + "version": "0.4.5", "source": { "type": "git", "url": "https://github.com/utopia-php/telemetry.git", - "reference": "028f3a85bb987b1e9357fb1632aa40f020d1b86e" + "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/028f3a85bb987b1e9357fb1632aa40f020d1b86e", - "reference": "028f3a85bb987b1e9357fb1632aa40f020d1b86e", + "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/139943bffcd4f6dd8fb9ed247f946a1d151b006a", + "reference": "139943bffcd4f6dd8fb9ed247f946a1d151b006a", "shasum": "" }, "require": { - "ext-opentelemetry": "*", "ext-protobuf": "*", "nyholm/psr7": "1.*", "open-telemetry/exporter-otlp": "1.*", @@ -2058,7 +2174,6 @@ "symfony/http-client": "7.*" }, "require-dev": { - "phpbench/phpbench": "1.*", "swoole/ide-helper": "6.*" }, "suggest": { @@ -2083,24 +2198,24 @@ ], "support": { "issues": "https://github.com/utopia-php/telemetry/issues", - "source": "https://github.com/utopia-php/telemetry/tree/0.4.1" + "source": "https://github.com/utopia-php/telemetry/tree/0.4.5" }, - "time": "2026-06-10T15:48:26+00:00" + "time": "2026-07-08T11:07:25+00:00" } ], "packages-dev": [ { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.3", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", "shasum": "" }, "require": { @@ -2111,14 +2226,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.8", + "illuminate/view": "^12.62.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "pestphp/pest": "^3.8.6" }, "bin": [ "builds/pint" @@ -2155,7 +2270,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-06-16T15:34:04+00:00" }, { "name": "myclabs/deep-copy", @@ -2219,20 +2334,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -2271,9 +2385,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -2395,15 +2509,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "2.2.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -2422,6 +2536,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -2444,7 +2569,7 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-07-05T06:31:06+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2795,24 +2920,24 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.55", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -2877,31 +3002,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "sebastian/cli-parser", @@ -3941,6 +4050,38 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "swoole/ide-helper", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "6f12243dce071714c5febe059578d909698f9a52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/6f12243dce071714c5febe059578d909698f9a52", + "reference": "6f12243dce071714c5febe059578d909698f9a52", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Team Swoole", + "email": "team@swoole.com" + } + ], + "description": "IDE help files for Swoole.", + "support": { + "issues": "https://github.com/swoole/ide-helper/issues", + "source": "https://github.com/swoole/ide-helper/tree/6.0.2" + }, + "time": "2025-03-23T07:31:41+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -3998,13 +4139,14 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1.0", + "php": ">=8.5.0", "ext-curl": "*", - "ext-openssl": "*" + "ext-openssl": "*", + "ext-swoole": "*" }, "platform-dev": {}, "platform-overrides": { - "php": "8.3" + "php": "8.5" }, "plugin-api-version": "2.9.0" } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..954472e6 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 6 + paths: + - src + - tests + scanDirectories: + - vendor/swoole/ide-helper diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index 3cc63f3b..9a72870e 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -3,15 +3,43 @@ namespace Utopia\Messaging; use Exception; -use libphonenumber\PhoneNumberUtil; +use JsonException; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Swoole\Coroutine; +use Swoole\Coroutine\WaitGroup; +use Utopia\Client; +use Utopia\Client\Adapter\Curl\Client as CurlAdapter; +use Utopia\Client\Adapter\SwooleCoroutine\Client as SwooleAdapter; +use Utopia\Client\Pool as ClientPool; +use Utopia\Pools\Adapter\Swoole as SwoolePoolAdapter; +use Utopia\Pools\Pool as ConnectionPool; +use Utopia\Psr7\Request\Factory as RequestFactory; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\Telemetry\Counter; abstract class Adapter { + /** + * Upper bound on the connection pool size used by requestMulti(). + */ + private const int MAX_CONCURRENT_REQUESTS = 25; + + /** + * Name of the connection pool used by requestMulti(). + */ + private const string CONNECTION_POOL_NAME = 'messaging'; + + /** + * Counter tracking sent messages, labelled by result, type and provider. + */ private Counter $sendCounter; + /** + * @param Telemetry|null $telemetry Telemetry adapter to record metrics with; defaults to a no-op adapter. + */ public function __construct(?Telemetry $telemetry = null) { $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); @@ -150,7 +178,7 @@ private function recordResponse(Message $message, array $response): void * errorCode: int * } * - * @throws \Exception If the request fails. + * @throws Exception If the request fails. */ protected function request( string $method, @@ -160,67 +188,32 @@ protected function request( int $timeout = 30, int $connectTimeout = 10 ): array { - $ch = \curl_init(); - - foreach ($headers as $header) { - if (\str_contains($header, 'application/json')) { - $body = \json_encode($body); - break; - } - if (\str_contains($header, 'application/x-www-form-urlencoded')) { - $body = \http_build_query($body); - break; - } - } - - if (!\is_null($body)) { - \curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - - if (\is_string($body)) { - $headers[] = 'Content-Length: '.\strlen($body); - } - } + $client = new Client(new CurlAdapter()) + ->withTimeout((float)$timeout) + ->withConnectTimeout((float)$connectTimeout) + ->withHeaders(['User-Agent' => "Appwrite {$this->getName()} Message Sender"]); - $responseHeaders = []; - - \curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_URL => $url, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_USERAGENT => "Appwrite {$this->getName()} Message Sender", - CURLOPT_TIMEOUT => $timeout, - CURLOPT_CONNECTTIMEOUT => $connectTimeout, - CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$responseHeaders): int { - $parts = \explode(':', $header, 2); - if (\count($parts) === 2) { - $responseHeaders[\strtolower(\trim($parts[0]))] = \trim($parts[1]); - } - - return \strlen($header); - }, - ]); - - $response = \curl_exec($ch); + $request = $this->buildRequest($method, $url, $headers, $body); try { - $response = \json_decode($response, true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException) { - // Ignore + $response = $client->sendRequest($request); + } catch (ClientExceptionInterface $error) { + return [ + 'url' => $url, + 'statusCode' => 0, + 'response' => null, + 'headers' => [], + 'error' => $error->getMessage(), + 'errorCode' => $error->getCode(), + ]; } - return [ - 'url' => $url, - 'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE), - 'response' => $response, - 'headers' => $responseHeaders, - 'error' => \curl_error($ch), - 'errorCode' => \curl_errno($ch), - ]; + return $this->buildResult($response, $url); } /** - * Send multiple concurrent HTTP requests using HTTP/2 multiplexing. + * Send multiple concurrent HTTP requests using Swoole coroutines over a + * bounded connection pool. * * @param array $urls * @param array $headers @@ -249,40 +242,6 @@ protected function requestMulti( throw new \Exception('No URLs provided. Must provide at least one URL.'); } - foreach ($headers as $header) { - if (\str_contains($header, 'application/json')) { - foreach ($bodies as $i => $body) { - $bodies[$i] = \json_encode($body); - } - break; - } - if (\str_contains($header, 'application/x-www-form-urlencoded')) { - foreach ($bodies as $i => $body) { - $bodies[$i] = \http_build_query($body); - } - break; - } - } - - $sh = \curl_share_init(); - $mh = \curl_multi_init(); - $ch = \curl_init(); - - \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS); - \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); - - \curl_setopt_array($ch, [ - CURLOPT_SHARE => $sh, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FORBID_REUSE => false, - CURLOPT_FRESH_CONNECT => false, - CURLOPT_TIMEOUT => $timeout, - CURLOPT_CONNECTTIMEOUT => $connectTimeout, - ]); - $urlCount = \count($urls); $bodyCount = \count($bodies); @@ -296,85 +255,146 @@ protected function requestMulti( $urls = \array_pad($urls, $bodyCount, $urls[0]); } - for ($i = 0; $i < \count($urls); $i++) { - if (!empty($bodies[$i])) { - $headers[] = 'Content-Length: '.\strlen($bodies[$i]); - } - - \curl_setopt($ch, CURLOPT_URL, $urls[$i]); - \curl_setopt($ch, CURLOPT_POSTFIELDS, $bodies[$i]); - \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - \curl_setopt($ch, CURLOPT_PRIVATE, $i); - \curl_multi_add_handle($mh, \curl_copy_handle($ch)); + $requests = []; + foreach ($urls as $i => $url) { + $requests[$i] = $this->buildRequest($method, $url, $headers, $bodies[$i] ?: null); } - $active = true; - do { - $status = \curl_multi_exec($mh, $active); - - if ($active) { - \curl_multi_select($mh); + $results = []; + + $run = function () use ($requests, $timeout, $connectTimeout, &$results): void { + $pool = new ClientPool(new ConnectionPool( + pool: new SwoolePoolAdapter(), + name: self::CONNECTION_POOL_NAME, + size: \min(\count($requests), self::MAX_CONCURRENT_REQUESTS), + init: new Client(new SwooleAdapter()) + ->withTimeout((float)$timeout) + ->withConnectTimeout((float)$connectTimeout) + ->withHeaders(['User-Agent' => "Appwrite {$this->getName()} Message Sender"]) + ->withConnectionReuse(...), + )); + + $group = new WaitGroup(); + + foreach ($requests as $index => $request) { + $group->add(); + + Coroutine::create(function () use ($pool, $request, $index, &$results, $group): void { + try { + $results[$index] = $this->buildResult($pool->sendRequest($request), (string)$request->getUri()); + } catch (ClientExceptionInterface $error) { + $results[$index] = [ + 'url' => (string)$request->getUri(), + 'statusCode' => 0, + 'response' => null, + 'headers' => [], + 'error' => $error->getMessage(), + 'errorCode' => $error->getCode(), + ]; + } finally { + $group->done(); + } + }); } - } while ($active && $status == CURLM_OK); + + $group->wait(); + }; + + // Fan out directly when already inside a coroutine runtime (e.g. + // Swoole servers/workers); otherwise bootstrap a scheduler for the + // duration of the batch. + if (Coroutine::getCid() > 0) { + $run(); + } else { + \Swoole\Coroutine\run($run); + } $responses = []; + foreach ($results as $index => $result) { + $responses[] = ['index' => $index] + $result; + } - // Check each handle's result - while ($info = \curl_multi_info_read($mh)) { - $ch = $info['handle']; + return $responses; + } - $response = \curl_multi_getcontent($ch); + /** + * Build a PSR-7 request, encoding the body based on the request headers: + * JSON, form-urlencoded, or multipart/form-data (mirroring curl's + * handling of array CURLOPT_POSTFIELDS) in that order of precedence. + * + * @param array $headers Headers as "Name: value" strings. + * @param array|null $body + */ + private function buildRequest(string $method, string $url, array $headers, ?array $body): RequestInterface + { + $factory = new RequestFactory(); - try { - $response = \json_decode($response, true, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException) { - // Ignore + $headerMap = []; + foreach ($headers as $header) { + $parts = \explode(':', $header, 2); + if (\count($parts) === 2) { + $headerMap[\trim($parts[0])] = \trim($parts[1]); } + } - $responses[] = [ - 'index' => (int)\curl_getinfo($ch, CURLINFO_PRIVATE), - 'url' => \curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), - 'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE), - 'response' => $response, - // Kept in sync with request()'s shape. Response headers are not - // captured here: this path copies a configured handle with - // curl_copy_handle(), and copying a handle that carries a - // CURLOPT_HEADERFUNCTION closure segfaults. Wire up per-handle - // capture (without copy_handle) if a multi-path adapter needs it. - 'headers' => [], - 'error' => \curl_error($ch), - 'errorCode' => $info['result'], - ]; + if ($body === null) { + return $factory->query($method, $url, [], $headerMap); + } - \curl_multi_remove_handle($mh, $ch); + foreach ($headers as $header) { + if (\str_contains($header, 'application/json')) { + return $factory->json($method, $url, $body, $headerMap); + } + if (\str_contains($header, 'application/x-www-form-urlencoded')) { + return $factory->form($method, $url, $body, $headerMap); + } } - \curl_share_close($sh); + // Drop any bare multipart Content-Type so the factory can set one + // carrying the boundary. + foreach (\array_keys($headerMap) as $name) { + if (\strtolower($name) === 'content-type') { + unset($headerMap[$name]); + } + } - return $responses; + return $factory->multipart($method, $url, $body, $headerMap); } - /** - * @param string $phone - * @return int|null - * @throws Exception + * Map a PSR-7 response to the array shape adapters consume. + * + * @return array{ + * url: string, + * statusCode: int, + * response: array|string|null, + * headers: array, + * error: string|null, + * errorCode: int + * } */ - public function getCountryCode(string $phone): ?int + private function buildResult(ResponseInterface $response, string $url): array { - if (empty($phone)) { - throw new Exception('$phone cannot be empty.'); - } - - $helper = PhoneNumberUtil::getInstance(); + $body = (string)$response->getBody(); try { - return $helper - ->parse($phone) - ->getCountryCode(); + $body = \json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + // Ignore + } - } catch (\Throwable $th) { - throw new Exception("Error parsing phone: " . $th->getMessage()); + $headers = []; + foreach (\array_keys($response->getHeaders()) as $name) { + $headers[\strtolower((string)$name)] = $response->getHeaderLine((string)$name); } + + return [ + 'url' => $url, + 'statusCode' => $response->getStatusCode(), + 'response' => $body, + 'headers' => $headers, + 'error' => '', + 'errorCode' => 0, + ]; } } diff --git a/src/Utopia/Messaging/Adapter/Email/Mailgun.php b/src/Utopia/Messaging/Adapter/Email/Mailgun.php index c166f7b1..77c6042e 100644 --- a/src/Utopia/Messaging/Adapter/Email/Mailgun.php +++ b/src/Utopia/Messaging/Adapter/Email/Mailgun.php @@ -5,6 +5,7 @@ use Utopia\Messaging\Adapter\Email as EmailAdapter; use Utopia\Messaging\Messages\Email as EmailMessage; use Utopia\Messaging\Response; +use Utopia\Psr7\Request\Multipart\Part; class Mailgun extends EmailAdapter { @@ -117,10 +118,11 @@ protected function process(EmailMessage $message): array foreach ($message->getAttachments() as $index => $attachment) { $isMultipart = true; - $body["attachment[$index]"] = \curl_file_create( + $body["attachment[$index]"] = Part::file( + "attachment[$index]", $attachment->getPath(), - $attachment->getType(), $attachment->getName(), + $attachment->getType(), ); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index e88d8a57..f5e8ac0b 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -96,7 +96,6 @@ protected function process(SMS $message): array } while (count($recipients) > 0); foreach ($batches as $index => $batch) { - /** @var array|null $metadata */ $metadata = $message->getMetadata(); if (\count($batches) > 1 && $metadata !== null) { foreach ([MetadataParameter::CRQID, MetadataParameter::UUID] as $parameter) { diff --git a/src/Utopia/Messaging/Helpers/JWT.php b/src/Utopia/Messaging/Helpers/JWT.php index ba4f4b83..e6020ef2 100644 --- a/src/Utopia/Messaging/Helpers/JWT.php +++ b/src/Utopia/Messaging/Helpers/JWT.php @@ -23,7 +23,7 @@ class JWT * * @throws \Exception */ - public static function encode(array $payload, string $key, string $algorithm, string $keyId = null): string + public static function encode(array $payload, string $key, string $algorithm, ?string $keyId = null): string { $header = [ 'typ' => 'JWT', diff --git a/src/Utopia/Messaging/Messages/SMS.php b/src/Utopia/Messaging/Messages/SMS.php index 9ed1dfe5..bbab2aa7 100644 --- a/src/Utopia/Messaging/Messages/SMS.php +++ b/src/Utopia/Messaging/Messages/SMS.php @@ -11,7 +11,7 @@ class SMS implements Message /** * @param array $to * @param array|null $attachments - * @param array|null $metadata + * @param array|null $metadata */ public function __construct( private array $to, @@ -49,7 +49,7 @@ public function getAttachments(): ?array } /** - * @return array|null + * @return array|null */ public function getMetadata(): ?array { @@ -57,7 +57,7 @@ public function getMetadata(): ?array } /** - * @param array|null $metadata + * @param array|null $metadata */ public function setMetadata(?array $metadata): self { diff --git a/tests/Messaging/Adapter/Chat/DiscordTest.php b/tests/Messaging/Adapter/Chat/DiscordTest.php index 3147c34a..4680d456 100644 --- a/tests/Messaging/Adapter/Chat/DiscordTest.php +++ b/tests/Messaging/Adapter/Chat/DiscordTest.php @@ -64,7 +64,7 @@ public function testValidURLVariations(): void try { $discord = new Discord($url); // If we get here, the URL was accepted - $this->assertTrue(true, "Valid URL variant '{$label}' was accepted as expected"); + $this->addToAssertionCount(1); } catch (InvalidArgumentException $e) { $this->fail("Valid URL variant '{$label}' was rejected: " . $e->getMessage()); } diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index 893d87d6..03e7d76f 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -190,7 +190,6 @@ public function testSendSMSHandlesMetadata(): void $this->assertEquals('success', $result['local']['results'][0]['status']); $this->assertEquals('success', $result['default']['results'][0]['status']); - /** @var array $invalidMetadata */ $invalidMetadata = [ 'CRQID' => [], ]; diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index 9457f659..4548d8a9 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -70,7 +70,6 @@ public function testSendSMSHandlesMetadata(): void $this->assertStringContainsString('Msg91 CRQID metadata must be 80 characters or less', $e->getMessage()); } - /** @var array $metadata */ $metadata = [ 'CRQID' => null, ]; diff --git a/tests/Messaging/Adapter/SMS/SMSTest.php b/tests/Messaging/Adapter/SMS/SMSTest.php index 9ce9058e..848b034a 100644 --- a/tests/Messaging/Adapter/SMS/SMSTest.php +++ b/tests/Messaging/Adapter/SMS/SMSTest.php @@ -32,7 +32,5 @@ public function testSendSMS(): void $this->assertEquals('POST', $smsRequest['method']); $this->assertEquals('+987654321', $smsRequest['data']['from']); $this->assertEquals('+123456789', $smsRequest['data']['to']); - $this->assertEquals(98, $sender->getCountryCode($smsRequest['data']['from'])); - $this->assertEquals(1, $sender->getCountryCode($smsRequest['data']['to'])); } } From 8aa18b285b54fca383818b8eb99cf0ff7463f986 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 14:06:03 +0530 Subject: [PATCH 2/8] (fix): restore HTTP/2 for pooled multi requests and null-safe APNS errors APNs only accepts HTTP/2, which Swoole's Coroutine\Http\Client cannot negotiate (connection reset). The requestMulti() pool now uses the client's cURL adapter with CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0; Swoole's native-curl hook (enabled by Coroutine\run and by Appwrite's runtime) keeps sends concurrent per coroutine. APNS error extraction now falls back to the transport error or 'Unknown error' instead of passing null to Response::addResult(). --- src/Utopia/Messaging/Adapter.php | 6 ++++-- src/Utopia/Messaging/Adapter/Push/APNS.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index 9a72870e..d31556b4 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -11,7 +11,6 @@ use Swoole\Coroutine\WaitGroup; use Utopia\Client; use Utopia\Client\Adapter\Curl\Client as CurlAdapter; -use Utopia\Client\Adapter\SwooleCoroutine\Client as SwooleAdapter; use Utopia\Client\Pool as ClientPool; use Utopia\Pools\Adapter\Swoole as SwoolePoolAdapter; use Utopia\Pools\Pool as ConnectionPool; @@ -267,7 +266,10 @@ protected function requestMulti( pool: new SwoolePoolAdapter(), name: self::CONNECTION_POOL_NAME, size: \min(\count($requests), self::MAX_CONCURRENT_REQUESTS), - init: new Client(new SwooleAdapter()) + // cURL rather than Swoole's HTTP client: APNs only accepts + // HTTP/2, which Coroutine\Http\Client cannot negotiate. Swoole's + // native-curl hook keeps these sends concurrent per coroutine. + init: new Client(new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])) ->withTimeout((float)$timeout) ->withConnectTimeout((float)$connectTimeout) ->withHeaders(['User-Agent' => "Appwrite {$this->getName()} Message Sender"]) diff --git a/src/Utopia/Messaging/Adapter/Push/APNS.php b/src/Utopia/Messaging/Adapter/Push/APNS.php index 2e6e4b45..f46f342c 100644 --- a/src/Utopia/Messaging/Adapter/Push/APNS.php +++ b/src/Utopia/Messaging/Adapter/Push/APNS.php @@ -135,7 +135,7 @@ public function process(PushMessage $message): array default: $error = ($result['response']['reason'] ?? null) === 'ExpiredToken' || ($result['response']['reason'] ?? null) === 'BadDeviceToken' ? $this->getExpiredErrorMessage() - : $result['response']['reason']; + : ($result['response']['reason'] ?? ($result['error'] ?: 'Unknown error')); $response->addResult($device, $error); break; From 273db68c7061fece39b002e481fdfd7806850d5b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 14:37:31 +0530 Subject: [PATCH 3/8] (feat): accept a PSR-18 client via the Adapter constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter now takes an optional Psr\Http\Client\ClientInterface (constructor param + setClient(), mirroring the telemetry pattern). Consumers can inject any PSR-18 implementation — pooled/retry-decorated utopia clients, or an in-memory mock for network-free tests. When none is injected, the previous defaults are built internally (HTTP/2 cURL adapter; pooled for requestMulti). The User-Agent moved from client config onto the request so injected clients send the same identity. Injected clients must negotiate HTTP/2 for APNs (documented on the constructor). --- composer.json | 2 + composer.lock | 2 +- src/Utopia/Messaging/Adapter.php | 62 +++++-- tests/Messaging/Adapter/MockClientTest.php | 203 +++++++++++++++++++++ 4 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 tests/Messaging/Adapter/MockClientTest.php diff --git a/composer.json b/composer.json index 845c3c00..5299e752 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,8 @@ "ext-openssl": "*", "ext-swoole": "*", "phpmailer/phpmailer": "^6.9", + "psr/http-client": "^1.0", + "psr/http-message": "^1.1 || ^2.0", "utopia-php/client": "^0.2", "utopia-php/telemetry": "^0.4" }, diff --git a/composer.lock b/composer.lock index 72fc944c..7ad8ac66 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a9994addcba6c87fd84f4222054d804d", + "content-hash": "bb61dfe720e1e98b65f2ccefdfc28d41", "packages": [ { "name": "brick/math", diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index d31556b4..bf56eda3 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -5,6 +5,7 @@ use Exception; use JsonException; use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Swoole\Coroutine; @@ -38,8 +39,16 @@ abstract class Adapter /** * @param Telemetry|null $telemetry Telemetry adapter to record metrics with; defaults to a no-op adapter. + * @param ClientInterface|null $client PSR-18 client used for all HTTP requests; defaults to + * utopia-php/client's cURL adapter configured for HTTP/2 with the request()/requestMulti() + * timeouts applied. An injected client owns its own timeout configuration, and must be + * able to negotiate HTTP/2 for push adapters — APNs rejects HTTP/1.1 connections, so a + * bare `new Client(new CurlAdapter())` will not work; configure the adapter with + * `new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])`. For + * concurrent requestMulti() fan-out, inject a `Utopia\Client\Pool`-wrapped client; + * a non-pooled client serializes the batch on its single connection. */ - public function __construct(?Telemetry $telemetry = null) + public function __construct(?Telemetry $telemetry = null, private ?ClientInterface $client = null) { $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); } @@ -108,6 +117,15 @@ public function setTelemetry(Telemetry $telemetry): void $this->sendCounter = $telemetry->createCounter('messaging.send'); } + /** + * Set the PSR-18 client used for all HTTP requests. See the constructor + * doc for the HTTP/2 and pooling requirements an injected client must meet. + */ + public function setClient(ClientInterface $client): void + { + $this->client = $client; + } + private function recordSend(Message $message, int $recipients, int $delivered): void { if ($delivered > 0) { @@ -187,11 +205,7 @@ protected function request( int $timeout = 30, int $connectTimeout = 10 ): array { - $client = new Client(new CurlAdapter()) - ->withTimeout((float)$timeout) - ->withConnectTimeout((float)$connectTimeout) - ->withHeaders(['User-Agent' => "Appwrite {$this->getName()} Message Sender"]); - + $client = $this->client ?? $this->defaultClient($timeout, $connectTimeout); $request = $this->buildRequest($method, $url, $headers, $body); try { @@ -262,18 +276,11 @@ protected function requestMulti( $results = []; $run = function () use ($requests, $timeout, $connectTimeout, &$results): void { - $pool = new ClientPool(new ConnectionPool( + $client = $this->client ?? new ClientPool(new ConnectionPool( pool: new SwoolePoolAdapter(), name: self::CONNECTION_POOL_NAME, size: \min(\count($requests), self::MAX_CONCURRENT_REQUESTS), - // cURL rather than Swoole's HTTP client: APNs only accepts - // HTTP/2, which Coroutine\Http\Client cannot negotiate. Swoole's - // native-curl hook keeps these sends concurrent per coroutine. - init: new Client(new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])) - ->withTimeout((float)$timeout) - ->withConnectTimeout((float)$connectTimeout) - ->withHeaders(['User-Agent' => "Appwrite {$this->getName()} Message Sender"]) - ->withConnectionReuse(...), + init: $this->defaultClient($timeout, $connectTimeout)->withConnectionReuse(...), )); $group = new WaitGroup(); @@ -281,9 +288,9 @@ protected function requestMulti( foreach ($requests as $index => $request) { $group->add(); - Coroutine::create(function () use ($pool, $request, $index, &$results, $group): void { + Coroutine::create(function () use ($client, $request, $index, &$results, $group): void { try { - $results[$index] = $this->buildResult($pool->sendRequest($request), (string)$request->getUri()); + $results[$index] = $this->buildResult($client->sendRequest($request), (string)$request->getUri()); } catch (ClientExceptionInterface $error) { $results[$index] = [ 'url' => (string)$request->getUri(), @@ -319,6 +326,21 @@ protected function requestMulti( return $responses; } + /** + * Build the default HTTP client used when none was injected. + * + * cURL rather than Swoole's HTTP client: APNs only accepts HTTP/2, which + * Coroutine\Http\Client cannot negotiate (curl falls back to HTTP/1.1 for + * servers without it). Swoole's native-curl hook keeps requestMulti() + * sends concurrent per coroutine. + */ + private function defaultClient(int $timeout, int $connectTimeout): Client + { + return new Client(new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])) + ->withTimeout((float)$timeout) + ->withConnectTimeout((float)$connectTimeout); + } + /** * Build a PSR-7 request, encoding the body based on the request headers: * JSON, form-urlencoded, or multipart/form-data (mirroring curl's @@ -339,6 +361,12 @@ private function buildRequest(string $method, string $url, array $headers, ?arra } } + // On the request rather than the client so injected PSR-18 clients + // send the same identity. + if (!\array_any(\array_keys($headerMap), fn (string $name): bool => \strtolower($name) === 'user-agent')) { + $headerMap['User-Agent'] = "Appwrite {$this->getName()} Message Sender"; + } + if ($body === null) { return $factory->query($method, $url, [], $headerMap); } diff --git a/tests/Messaging/Adapter/MockClientTest.php b/tests/Messaging/Adapter/MockClientTest.php new file mode 100644 index 00000000..8178fc28 --- /dev/null +++ b/tests/Messaging/Adapter/MockClientTest.php @@ -0,0 +1,203 @@ + + */ + public array $requests = []; + + /** + * @var array + */ + public array $queue = []; + + public function queueResponse(int $statusCode, string $body = ''): void + { + $this->queue[] = new Response($statusCode, body: new Stream($body)); + } + + public function queueException(ClientExceptionInterface $exception): void + { + $this->queue[] = $exception; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + $next = \array_shift($this->queue) ?? new Response(200); + + if ($next instanceof ClientExceptionInterface) { + throw $next; + } + + return $next; + } +} + +class MockNetworkException extends \Exception implements NetworkExceptionInterface +{ + public function __construct( + private readonly RequestInterface $request, + string $message, + int $code = 0, + ) { + parent::__construct($message, $code); + } + + public function getRequest(): RequestInterface + { + return $this->request; + } +} + +class MultiAdapter extends Adapter +{ + public function getName(): string + { + return 'Multi'; + } + + public function getType(): string + { + return 'sms'; + } + + public function getMessageType(): string + { + return SMS::class; + } + + public function getMaxMessagesPerRequest(): int + { + return 100; + } + + /** + * @param array> $bodies + * @return array> + */ + public function sendBatch(array $bodies): array + { + return $this->requestMulti( + method: 'POST', + urls: ['https://example.test/batch'], + headers: ['Content-Type: application/json'], + bodies: $bodies, + ); + } + + /** + * @param array $body + * @return array + */ + public function sendOne(array $body): array + { + return $this->request( + method: 'POST', + url: 'https://example.test/single', + headers: ['Content-Type: application/json'], + body: $body, + ); + } +} + +class MockClientTest extends TestCase +{ + public function testSendUsesInjectedClient(): void + { + $client = new MockClient(); + $client->queueResponse(200, '{"ok":true}'); + + $adapter = new Mock('username', 'password'); + $adapter->setClient($client); + + $result = $adapter->send(new SMS( + to: ['+123456789'], + content: 'Test Content', + from: '+987654321', + )); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + + $this->assertCount(1, $client->requests); + $request = $client->requests[0]; + + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('http://request-catcher:5000/mock-sms', (string)$request->getUri()); + $this->assertEquals('application/json', $request->getHeaderLine('Content-Type')); + $this->assertEquals('Appwrite Mock Message Sender', $request->getHeaderLine('User-Agent')); + $this->assertEquals('username', $request->getHeaderLine('X-Username')); + + $body = \json_decode((string)$request->getBody(), true); + $this->assertEquals('+987654321', $body['from']); + $this->assertEquals('+123456789', $body['to']); + $this->assertEquals('Test Content', $body['message']); + } + + public function testNetworkErrorMapsToErrorResult(): void + { + $client = new MockClient(); + $client->queueException(new MockNetworkException( + new \Utopia\Psr7\Request('POST', \Utopia\Psr7\Uri::parse('https://example.test/single')), + 'Connection refused', + 7, + )); + + $adapter = new MultiAdapter(client: $client); + + $result = $adapter->sendOne(['n' => 0]); + + $this->assertEquals(0, $result['statusCode']); + $this->assertNull($result['response']); + $this->assertEquals('Connection refused', $result['error']); + $this->assertEquals(7, $result['errorCode']); + } + + public function testRequestMultiUsesInjectedClient(): void + { + $client = new MockClient(); + $client->queueResponse(200, '{"n":0}'); + $client->queueResponse(500, '{"n":1}'); + $client->queueResponse(200, '{"n":2}'); + + $adapter = new MultiAdapter(client: $client); + + $results = $adapter->sendBatch([ + ['n' => 0], + ['n' => 1], + ['n' => 2], + ]); + + $this->assertCount(3, $client->requests); + $this->assertCount(3, $results); + + $byIndex = \array_column($results, null, 'index'); + $this->assertEquals([200, 500, 200], [ + $byIndex[0]['statusCode'], + $byIndex[1]['statusCode'], + $byIndex[2]['statusCode'], + ]); + + foreach ($client->requests as $request) { + $this->assertEquals('https://example.test/batch', (string)$request->getUri()); + $this->assertEquals('Appwrite Multi Message Sender', $request->getHeaderLine('User-Agent')); + } + } +} From e0a60f4aeffea858bdfdfbfc3d32467f611ee7c4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 14:39:28 +0530 Subject: [PATCH 4/8] (test): drop MockClientTest for now --- tests/Messaging/Adapter/MockClientTest.php | 203 --------------------- 1 file changed, 203 deletions(-) delete mode 100644 tests/Messaging/Adapter/MockClientTest.php diff --git a/tests/Messaging/Adapter/MockClientTest.php b/tests/Messaging/Adapter/MockClientTest.php deleted file mode 100644 index 8178fc28..00000000 --- a/tests/Messaging/Adapter/MockClientTest.php +++ /dev/null @@ -1,203 +0,0 @@ - - */ - public array $requests = []; - - /** - * @var array - */ - public array $queue = []; - - public function queueResponse(int $statusCode, string $body = ''): void - { - $this->queue[] = new Response($statusCode, body: new Stream($body)); - } - - public function queueException(ClientExceptionInterface $exception): void - { - $this->queue[] = $exception; - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - $this->requests[] = $request; - - $next = \array_shift($this->queue) ?? new Response(200); - - if ($next instanceof ClientExceptionInterface) { - throw $next; - } - - return $next; - } -} - -class MockNetworkException extends \Exception implements NetworkExceptionInterface -{ - public function __construct( - private readonly RequestInterface $request, - string $message, - int $code = 0, - ) { - parent::__construct($message, $code); - } - - public function getRequest(): RequestInterface - { - return $this->request; - } -} - -class MultiAdapter extends Adapter -{ - public function getName(): string - { - return 'Multi'; - } - - public function getType(): string - { - return 'sms'; - } - - public function getMessageType(): string - { - return SMS::class; - } - - public function getMaxMessagesPerRequest(): int - { - return 100; - } - - /** - * @param array> $bodies - * @return array> - */ - public function sendBatch(array $bodies): array - { - return $this->requestMulti( - method: 'POST', - urls: ['https://example.test/batch'], - headers: ['Content-Type: application/json'], - bodies: $bodies, - ); - } - - /** - * @param array $body - * @return array - */ - public function sendOne(array $body): array - { - return $this->request( - method: 'POST', - url: 'https://example.test/single', - headers: ['Content-Type: application/json'], - body: $body, - ); - } -} - -class MockClientTest extends TestCase -{ - public function testSendUsesInjectedClient(): void - { - $client = new MockClient(); - $client->queueResponse(200, '{"ok":true}'); - - $adapter = new Mock('username', 'password'); - $adapter->setClient($client); - - $result = $adapter->send(new SMS( - to: ['+123456789'], - content: 'Test Content', - from: '+987654321', - )); - - $this->assertEquals(1, $result['deliveredTo']); - $this->assertEquals('success', $result['results'][0]['status']); - - $this->assertCount(1, $client->requests); - $request = $client->requests[0]; - - $this->assertEquals('POST', $request->getMethod()); - $this->assertEquals('http://request-catcher:5000/mock-sms', (string)$request->getUri()); - $this->assertEquals('application/json', $request->getHeaderLine('Content-Type')); - $this->assertEquals('Appwrite Mock Message Sender', $request->getHeaderLine('User-Agent')); - $this->assertEquals('username', $request->getHeaderLine('X-Username')); - - $body = \json_decode((string)$request->getBody(), true); - $this->assertEquals('+987654321', $body['from']); - $this->assertEquals('+123456789', $body['to']); - $this->assertEquals('Test Content', $body['message']); - } - - public function testNetworkErrorMapsToErrorResult(): void - { - $client = new MockClient(); - $client->queueException(new MockNetworkException( - new \Utopia\Psr7\Request('POST', \Utopia\Psr7\Uri::parse('https://example.test/single')), - 'Connection refused', - 7, - )); - - $adapter = new MultiAdapter(client: $client); - - $result = $adapter->sendOne(['n' => 0]); - - $this->assertEquals(0, $result['statusCode']); - $this->assertNull($result['response']); - $this->assertEquals('Connection refused', $result['error']); - $this->assertEquals(7, $result['errorCode']); - } - - public function testRequestMultiUsesInjectedClient(): void - { - $client = new MockClient(); - $client->queueResponse(200, '{"n":0}'); - $client->queueResponse(500, '{"n":1}'); - $client->queueResponse(200, '{"n":2}'); - - $adapter = new MultiAdapter(client: $client); - - $results = $adapter->sendBatch([ - ['n' => 0], - ['n' => 1], - ['n' => 2], - ]); - - $this->assertCount(3, $client->requests); - $this->assertCount(3, $results); - - $byIndex = \array_column($results, null, 'index'); - $this->assertEquals([200, 500, 200], [ - $byIndex[0]['statusCode'], - $byIndex[1]['statusCode'], - $byIndex[2]['statusCode'], - ]); - - foreach ($client->requests as $request) { - $this->assertEquals('https://example.test/batch', (string)$request->getUri()); - $this->assertEquals('Appwrite Multi Message Sender', $request->getHeaderLine('User-Agent')); - } - } -} From 2176088a6d498833c3bf4879010d54810e9df6c1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 14:41:15 +0530 Subject: [PATCH 5/8] (fix): address review - preserve empty-array bodies and tighten error docblock requestMulti() uses null-coalescing instead of the elvis operator so an explicit empty-array body is still sent; the result shape docblocks now declare error as string since null is never produced. --- src/Utopia/Messaging/Adapter.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index bf56eda3..9f2e16b6 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -191,7 +191,7 @@ private function recordResponse(Message $message, array $response): void * statusCode: int, * response: array|string|null, * headers: array, - * error: string|null, + * error: string, * errorCode: int * } * @@ -237,7 +237,7 @@ protected function request( * statusCode: int, * response: array|string|null, * headers: array, - * error: string|null, + * error: string, * errorCode: int * }> * @@ -270,7 +270,7 @@ protected function requestMulti( $requests = []; foreach ($urls as $i => $url) { - $requests[$i] = $this->buildRequest($method, $url, $headers, $bodies[$i] ?: null); + $requests[$i] = $this->buildRequest($method, $url, $headers, $bodies[$i] ?? null); } $results = []; @@ -399,7 +399,7 @@ private function buildRequest(string $method, string $url, array $headers, ?arra * statusCode: int, * response: array|string|null, * headers: array, - * error: string|null, + * error: string, * errorCode: int * } */ From 71a8ae7f9cd5cd77faae22acc822717ebda5d289 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 15:32:08 +0530 Subject: [PATCH 6/8] (refactor): inject a client factory instead of a client instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter now takes (Closure(): ClientInterface)|null $clientFactory (constructor param + setClientFactory()) instead of a single ClientInterface. requestMulti() always pools internally, calling the factory once per pooled connection — so consumers describe how to build one client and the adapter owns concurrency, removing the inject-a-Pool-or-serialize caveat. The pool is consumed via Pools\Pool::use() directly since consumer factories only promise PSR-18, not the streaming interface Utopia\Client\Pool requires. --- src/Utopia/Messaging/Adapter.php | 45 ++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index 9f2e16b6..02ca68f6 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -2,6 +2,7 @@ namespace Utopia\Messaging; +use Closure; use Exception; use JsonException; use Psr\Http\Client\ClientExceptionInterface; @@ -12,7 +13,6 @@ use Swoole\Coroutine\WaitGroup; use Utopia\Client; use Utopia\Client\Adapter\Curl\Client as CurlAdapter; -use Utopia\Client\Pool as ClientPool; use Utopia\Pools\Adapter\Swoole as SwoolePoolAdapter; use Utopia\Pools\Pool as ConnectionPool; use Utopia\Psr7\Request\Factory as RequestFactory; @@ -39,16 +39,16 @@ abstract class Adapter /** * @param Telemetry|null $telemetry Telemetry adapter to record metrics with; defaults to a no-op adapter. - * @param ClientInterface|null $client PSR-18 client used for all HTTP requests; defaults to - * utopia-php/client's cURL adapter configured for HTTP/2 with the request()/requestMulti() - * timeouts applied. An injected client owns its own timeout configuration, and must be - * able to negotiate HTTP/2 for push adapters — APNs rejects HTTP/1.1 connections, so a - * bare `new Client(new CurlAdapter())` will not work; configure the adapter with - * `new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])`. For - * concurrent requestMulti() fan-out, inject a `Utopia\Client\Pool`-wrapped client; - * a non-pooled client serializes the batch on its single connection. + * @param (Closure(): ClientInterface)|null $clientFactory Factory producing the PSR-18 clients + * used for HTTP requests — called once per request() and once per pooled requestMulti() + * connection, so it must return a new (or safely shareable) client on each call. Defaults + * to utopia-php/client's cURL adapter configured for HTTP/2 with the request()/requestMulti() + * timeouts applied. A custom factory owns its own timeout configuration, and its clients + * must be able to negotiate HTTP/2 for push adapters — APNs rejects HTTP/1.1 connections, + * so a bare `new Client(new CurlAdapter())` will not work; configure the adapter with + * `new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])`. */ - public function __construct(?Telemetry $telemetry = null, private ?ClientInterface $client = null) + public function __construct(?Telemetry $telemetry = null, private ?Closure $clientFactory = null) { $this->sendCounter = ($telemetry ?? new NoTelemetry())->createCounter('messaging.send'); } @@ -118,12 +118,14 @@ public function setTelemetry(Telemetry $telemetry): void } /** - * Set the PSR-18 client used for all HTTP requests. See the constructor - * doc for the HTTP/2 and pooling requirements an injected client must meet. + * Set the factory producing the PSR-18 clients used for HTTP requests. + * See the constructor doc for the requirements a factory must meet. + * + * @param Closure(): ClientInterface $clientFactory */ - public function setClient(ClientInterface $client): void + public function setClientFactory(Closure $clientFactory): void { - $this->client = $client; + $this->clientFactory = $clientFactory; } private function recordSend(Message $message, int $recipients, int $delivered): void @@ -205,7 +207,10 @@ protected function request( int $timeout = 30, int $connectTimeout = 10 ): array { - $client = $this->client ?? $this->defaultClient($timeout, $connectTimeout); + $client = $this->clientFactory !== null + ? ($this->clientFactory)() + : $this->defaultClient($timeout, $connectTimeout); + $request = $this->buildRequest($method, $url, $headers, $body); try { @@ -276,21 +281,21 @@ protected function requestMulti( $results = []; $run = function () use ($requests, $timeout, $connectTimeout, &$results): void { - $client = $this->client ?? new ClientPool(new ConnectionPool( + $pool = new ConnectionPool( pool: new SwoolePoolAdapter(), name: self::CONNECTION_POOL_NAME, size: \min(\count($requests), self::MAX_CONCURRENT_REQUESTS), - init: $this->defaultClient($timeout, $connectTimeout)->withConnectionReuse(...), - )); + init: $this->clientFactory ?? $this->defaultClient($timeout, $connectTimeout)->withConnectionReuse(...), + ); $group = new WaitGroup(); foreach ($requests as $index => $request) { $group->add(); - Coroutine::create(function () use ($client, $request, $index, &$results, $group): void { + Coroutine::create(function () use ($pool, $request, $index, &$results, $group): void { try { - $results[$index] = $this->buildResult($client->sendRequest($request), (string)$request->getUri()); + $results[$index] = $pool->use(fn (ClientInterface $client): array => $this->buildResult($client->sendRequest($request), (string)$request->getUri())); } catch (ClientExceptionInterface $error) { $results[$index] = [ 'url' => (string)$request->getUri(), From 70824d358bd4f8cac125efc52e66a51151f496d4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 15:35:28 +0530 Subject: [PATCH 7/8] (refactor): drop setClientFactory, keep constructor-only injection --- src/Utopia/Messaging/Adapter.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index 02ca68f6..eb171d0b 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -117,17 +117,6 @@ public function setTelemetry(Telemetry $telemetry): void $this->sendCounter = $telemetry->createCounter('messaging.send'); } - /** - * Set the factory producing the PSR-18 clients used for HTTP requests. - * See the constructor doc for the requirements a factory must meet. - * - * @param Closure(): ClientInterface $clientFactory - */ - public function setClientFactory(Closure $clientFactory): void - { - $this->clientFactory = $clientFactory; - } - private function recordSend(Message $message, int $recipients, int $delivered): void { if ($delivered > 0) { From 25e4963ffbba40c02746ebb7bb8afe77e1c9598f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 14 Jul 2026 15:48:20 +0530 Subject: [PATCH 8/8] (fix): catch Throwable in requestMulti coroutines so every slot gets a result --- src/Utopia/Messaging/Adapter.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index eb171d0b..33c8f6e1 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -285,14 +285,18 @@ protected function requestMulti( Coroutine::create(function () use ($pool, $request, $index, &$results, $group): void { try { $results[$index] = $pool->use(fn (ClientInterface $client): array => $this->buildResult($client->sendRequest($request), (string)$request->getUri())); - } catch (ClientExceptionInterface $error) { + } catch (\Throwable $error) { + // Throwable rather than the PSR client exception: pool + // acquisition and factory failures must also land in + // this slot's result — an uncaught throwable in a + // coroutine is fatal and would drop the slot entirely. $results[$index] = [ 'url' => (string)$request->getUri(), 'statusCode' => 0, 'response' => null, 'headers' => [], 'error' => $error->getMessage(), - 'errorCode' => $error->getCode(), + 'errorCode' => (int)$error->getCode(), ]; } finally { $group->done();