Skip to content

(feat): replace raw curl transport with utopia-php/client#137

Merged
ChiragAgg5k merged 8 commits into
mainfrom
feat/utopia-client-transport
Jul 14, 2026
Merged

(feat): replace raw curl transport with utopia-php/client#137
ChiragAgg5k merged 8 commits into
mainfrom
feat/utopia-client-transport

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Jul 14, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Replaces the raw curl_* transport in the base Adapter with utopia-php/client (PSR-18). Nothing changes for existing usage — every adapter keeps its API and behavior:

use Utopia\Messaging\Adapter\SMS\Twilio;
use Utopia\Messaging\Messages\SMS;

$adapter = new Twilio($accountSid, $authToken);

$result = $adapter->send(new SMS(
    to: ['+14155550100'],
    content: 'Hello!',
    from: '+14155550199',
));
// ['deliveredTo' => 1, 'type' => 'sms', 'results' => [...]]

Batched sends (FCM, APNS) remain concurrent — now via Swoole coroutines over a bounded connection pool instead of curl_multi.

New: bring your own HTTP client

Adapters accept an optional client factory (Closure(): ClientInterface), so any PSR-18 implementation can be used — with retries, a proxy, or an in-memory stub for tests. The adapter calls the factory whenever it needs a client and keeps handling pooling/concurrency itself:

The parameter lives on the abstract Adapter constructor, so today it's reachable from adapter subclasses; exposing it on the concrete adapters is a follow-up. Once threaded through, usage looks like:

use Psr\Http\Client\ClientInterface;
use Utopia\Client;
use Utopia\Client\Adapter\Curl\Client as CurlAdapter;
use Utopia\Messaging\Adapter\Email\Resend;

$adapter = new Resend(
    apiKey: $apiKey,
    clientFactory: fn (): ClientInterface => new Client(
        new CurlAdapter(options: [CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0])
    ),
);

$adapter->send($message);

If no factory is given, a suitable default is built internally. Factory-built clients must be able to negotiate HTTP/2 — APNs rejects HTTP/1.1 (see the constructor doc).

Breaking changes

Change Notes
PHP >=8.1>=8.5 required by utopia-php/client
ext-swoole now required powers concurrent batched sends
Adapter::getCountryCode() removed unused by the library; drops the libphonenumber dependency. Use CallingCode::fromPhoneNumber() for routing
SMS metadata type is now array<string, mixed> values are validated at runtime by the adapters that use them

Testing

  • Full suite passes in Docker (the only CI failures are stale account secrets: banned Mailgun key, SendGrid credits exhausted, expired FCM device token — same on main).
  • Transport verified end-to-end against the local request-catcher (JSON, form, multipart file upload, concurrent batches with index correlation) and against the live APNs sandbox over HTTP/2.
  • composer lint and composer analyse (phpstan level 6) pass.

Follow-ups

  • Expose the client-factory parameter on concrete adapters (as shown above) so consumers can inject without subclassing.
  • Optionally enable the client's Retry decorator by default in a future PR.

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).
…rors

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().
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the library's raw curl_* / curl_multi_* transport layer with utopia-php/client (PSR-18), introduces Swoole-coroutine-based concurrency for requestMulti(), and removes the getCountryCode() helper along with the giggsey/libphonenumber dependency. All existing adapter public APIs are preserved.

  • Adapter.php: New buildRequest() / buildResult() helpers centralise request building and response mapping; requestMulti() now fans out via a bounded ConnectionPool (up to 25 slots) with per-slot Throwable fallback so every request index always gets a result entry.
  • Mailgun.php: curl_file_create() replaced with Part::file() from utopia-php/psr7; parameter order (path, name, type) is preserved correctly.
  • Breaking changes: PHP bumped to >=8.5, ext-swoole is now required, getCountryCode() removed, SMS::$metadata widened to array<string, mixed>.

Confidence Score: 5/5

Safe to merge; the transport swap is complete and correct, concurrency fallback is handled per-slot, and the breaking changes are clearly documented.

The core logic changes are well-structured: every coroutine slot is guarded with a Throwable catch that populates the result array, the PSR-18 error path in request() also captures errors into the same array shape, and the buildRequest body-encoding logic faithfully mirrors the old curl behaviour. The two observations noted are non-blocking quality improvements rather than correctness issues.

No files require special attention; the two style observations in Adapter.php are non-blocking.

Important Files Changed

Filename Overview
src/Utopia/Messaging/Adapter.php Core refactor: replaces raw curl with PSR-18 utopia-php/client; adds Swoole coroutine pool for concurrent sends; removes getCountryCode(); introduces buildRequest/buildResult helpers. Logic is sound with proper Throwable fallback per coroutine slot.
src/Utopia/Messaging/Adapter/Push/APNS.php Error fallback updated: $result['response']['reason'] ?? ($result['error'] ?: 'Unknown error') correctly handles network-error slots where response is null and error is now always a non-null string.
src/Utopia/Messaging/Adapter/Email/Mailgun.php Replaces curl_file_create with Part::file(); parameter order is correct (fieldName, path, fileName, mimeType). Multipart and form-urlencoded paths unchanged.
src/Utopia/Messaging/Messages/SMS.php Metadata type widened from array<string,string> to array<string,mixed> to allow adapter-level runtime validation; a documented breaking change.
composer.json PHP requirement bumped to >=8.5.0, ext-swoole added as a hard dependency, utopia-php/client and PSR-18 packages introduced, giggsey/libphonenumber removed; all documented as breaking changes.
src/Utopia/Messaging/Helpers/JWT.php Fixes implicit nullable parameter: string $keyId = null?string $keyId = null — a PHP 8.4 deprecation fix.
phpstan.neon New config file moves PHPStan level 6 and scan paths out of the CLI command; adds vendor/swoole/ide-helper to scanDirectories for Swoole stubs.
Dockerfile Switches base image from php:8.3.11-cli-alpine3.20 to appwrite/utopia-base:php-8.5-2.1.0, which bundles Swoole and all required extensions.

Reviews (4): Last reviewed commit: "(fix): catch Throwable in requestMulti c..." | Re-trigger Greptile

Comment thread src/Utopia/Messaging/Adapter.php Outdated
Comment thread src/Utopia/Messaging/Adapter.php
Comment thread src/Utopia/Messaging/Adapter.php
Comment thread src/Utopia/Messaging/Adapter.php Outdated
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).
… 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.
Comment thread src/Utopia/Messaging/Adapter.php Outdated
Comment thread src/Utopia/Messaging/Adapter.php Outdated
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.
Comment thread src/Utopia/Messaging/Adapter.php Outdated
@ChiragAgg5k ChiragAgg5k merged commit 8890c3b into main Jul 14, 2026
3 of 4 checks passed
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