From 1bb68c78ee3090a7da40d4de2b1fc0b7ef825c16 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 23 Sep 2026 15:20:45 +0100 Subject: [PATCH] chore: retire client and span after their move to Appwrite utopia-php/client and utopia-php/span now live in appwrite/appwrite as packages/client and packages/span (appwrite/appwrite#13858). Drop them here so this repository stops discovering and publishing them during Split runs, refresh the dependency graph, and point readers at the new source location. The graph introduction now describes external requirements generally, since naming each one went stale with every retirement. --- README.md | 14 +- packages/client/.github/workflows/mirror.yml | 17 - packages/client/.gitignore | 4 - packages/client/CHANGELOG.md | 34 - packages/client/LICENSE.md | 21 - packages/client/README.md | 130 -- packages/client/composer.json | 57 - packages/client/docs/configuration.md | 171 --- packages/client/docs/development.md | 14 - packages/client/docs/error-handling.md | 120 -- packages/client/docs/pooling.md | 84 -- .../client/docs/psr/PSR-18-http-client.md | 161 --- packages/client/docs/retries.md | 108 -- packages/client/docs/streaming.md | 102 -- packages/client/docs/testing-requirements.md | 33 - packages/client/phpstan.neon | 6 - packages/client/phpunit.xml | 13 - packages/client/rector.php | 9 - packages/client/src/Adapter.php | 41 - packages/client/src/Adapter/Curl/Client.php | 567 -------- .../src/Adapter/SwooleCoroutine/Client.php | 955 -------------- packages/client/src/Client.php | 249 ---- packages/client/src/Decorator.php | 88 -- packages/client/src/Decorator/Retry.php | 102 -- .../client/src/Decorator/Retry/Backoff.php | 102 -- .../client/src/Decorator/Retry/Strategy.php | 21 - .../AdapterInitializationException.php | 7 - .../AdapterPreconditionException.php | 7 - .../src/Exception/ConnectionException.php | 7 - .../client/src/Exception/DnsException.php | 7 - .../Exception/InvalidResponseException.php | 7 - .../src/Exception/InvalidUriException.php | 7 - .../client/src/Exception/NetworkException.php | 27 - .../src/Exception/ProtocolException.php | 7 - .../client/src/Exception/ProxyException.php | 7 - .../client/src/Exception/RequestException.php | 27 - .../client/src/Exception/TimeoutException.php | 7 - .../client/src/Exception/TlsException.php | 7 - packages/client/src/Pool.php | 52 - .../src/Psr18/StreamingClientInterface.php | 28 - packages/client/src/Redirect.php | 184 --- packages/client/src/Response/Builder.php | 41 - packages/client/src/Tls.php | 20 - packages/client/src/compat.php | 11 - .../client/tests/Adapter/AdapterContract.php | 1141 ----------------- .../client/tests/Adapter/Curl/ClientTest.php | 145 --- .../Adapter/SwooleCoroutine/ClientTest.php | 285 ---- packages/client/tests/Adapter/TimeoutTest.php | 33 - packages/client/tests/ClientTest.php | 388 ------ packages/client/tests/CompatTest.php | 49 - .../tests/Decorator/Retry/BackoffTest.php | 112 -- packages/client/tests/Decorator/RetryTest.php | 257 ---- packages/client/tests/DecoratorTest.php | 116 -- packages/client/tests/ExceptionTest.php | 44 - packages/client/tests/OldNamesConsumer.php | 19 - packages/client/tests/PoolTest.php | 91 -- packages/client/tests/RedirectTest.php | 88 -- packages/client/tests/Server/Http.php | 352 ----- packages/client/tests/server.php | 408 ------ packages/span/.github/workflows/mirror.yml | 17 - packages/span/.gitignore | 4 - packages/span/CHANGELOG.md | 30 - packages/span/README.md | 288 ----- packages/span/composer.json | 31 - packages/span/phpstan.neon | 6 - packages/span/phpunit.xml | 17 - packages/span/rector.php | 18 - packages/span/src/Span/Exporter/Exporter.php | 34 - packages/span/src/Span/Exporter/None.php | 24 - packages/span/src/Span/Exporter/Pretty.php | 185 --- packages/span/src/Span/Exporter/Sentry.php | 496 ------- .../span/src/Span/Exporter/Sentry/Level.php | 35 - .../span/src/Span/Exporter/SentryField.php | 19 - packages/span/src/Span/Exporter/Stdout.php | 82 -- packages/span/src/Span/Level.php | 21 - packages/span/src/Span/Span.php | 253 ---- packages/span/src/Span/Storage/Auto.php | 49 - packages/span/src/Span/Storage/Coroutine.php | 52 - packages/span/src/Span/Storage/Memory.php | 25 - packages/span/src/Span/Storage/Storage.php | 30 - packages/span/tests/Exporter/NoneTest.php | 51 - packages/span/tests/Exporter/PrettyTest.php | 74 -- packages/span/tests/Exporter/SentryTest.php | 435 ------- packages/span/tests/Exporter/StdoutTest.php | 82 -- packages/span/tests/SpanTest.php | 673 ---------- packages/span/tests/Storage/AutoTest.php | 63 - packages/span/tests/Storage/CoroutineTest.php | 43 - packages/span/tests/Storage/MemoryTest.php | 62 - 88 files changed, 5 insertions(+), 10405 deletions(-) delete mode 100644 packages/client/.github/workflows/mirror.yml delete mode 100644 packages/client/.gitignore delete mode 100644 packages/client/CHANGELOG.md delete mode 100644 packages/client/LICENSE.md delete mode 100644 packages/client/README.md delete mode 100644 packages/client/composer.json delete mode 100644 packages/client/docs/configuration.md delete mode 100644 packages/client/docs/development.md delete mode 100644 packages/client/docs/error-handling.md delete mode 100644 packages/client/docs/pooling.md delete mode 100644 packages/client/docs/psr/PSR-18-http-client.md delete mode 100644 packages/client/docs/retries.md delete mode 100644 packages/client/docs/streaming.md delete mode 100644 packages/client/docs/testing-requirements.md delete mode 100644 packages/client/phpstan.neon delete mode 100644 packages/client/phpunit.xml delete mode 100644 packages/client/rector.php delete mode 100644 packages/client/src/Adapter.php delete mode 100644 packages/client/src/Adapter/Curl/Client.php delete mode 100644 packages/client/src/Adapter/SwooleCoroutine/Client.php delete mode 100644 packages/client/src/Client.php delete mode 100644 packages/client/src/Decorator.php delete mode 100644 packages/client/src/Decorator/Retry.php delete mode 100644 packages/client/src/Decorator/Retry/Backoff.php delete mode 100644 packages/client/src/Decorator/Retry/Strategy.php delete mode 100644 packages/client/src/Exception/AdapterInitializationException.php delete mode 100644 packages/client/src/Exception/AdapterPreconditionException.php delete mode 100644 packages/client/src/Exception/ConnectionException.php delete mode 100644 packages/client/src/Exception/DnsException.php delete mode 100644 packages/client/src/Exception/InvalidResponseException.php delete mode 100644 packages/client/src/Exception/InvalidUriException.php delete mode 100644 packages/client/src/Exception/NetworkException.php delete mode 100644 packages/client/src/Exception/ProtocolException.php delete mode 100644 packages/client/src/Exception/ProxyException.php delete mode 100644 packages/client/src/Exception/RequestException.php delete mode 100644 packages/client/src/Exception/TimeoutException.php delete mode 100644 packages/client/src/Exception/TlsException.php delete mode 100644 packages/client/src/Pool.php delete mode 100644 packages/client/src/Psr18/StreamingClientInterface.php delete mode 100644 packages/client/src/Redirect.php delete mode 100644 packages/client/src/Response/Builder.php delete mode 100644 packages/client/src/Tls.php delete mode 100644 packages/client/src/compat.php delete mode 100644 packages/client/tests/Adapter/AdapterContract.php delete mode 100644 packages/client/tests/Adapter/Curl/ClientTest.php delete mode 100644 packages/client/tests/Adapter/SwooleCoroutine/ClientTest.php delete mode 100644 packages/client/tests/Adapter/TimeoutTest.php delete mode 100644 packages/client/tests/ClientTest.php delete mode 100644 packages/client/tests/CompatTest.php delete mode 100644 packages/client/tests/Decorator/Retry/BackoffTest.php delete mode 100644 packages/client/tests/Decorator/RetryTest.php delete mode 100644 packages/client/tests/DecoratorTest.php delete mode 100644 packages/client/tests/ExceptionTest.php delete mode 100644 packages/client/tests/OldNamesConsumer.php delete mode 100644 packages/client/tests/PoolTest.php delete mode 100644 packages/client/tests/RedirectTest.php delete mode 100644 packages/client/tests/Server/Http.php delete mode 100644 packages/client/tests/server.php delete mode 100644 packages/span/.github/workflows/mirror.yml delete mode 100644 packages/span/.gitignore delete mode 100644 packages/span/CHANGELOG.md delete mode 100644 packages/span/README.md delete mode 100644 packages/span/composer.json delete mode 100644 packages/span/phpstan.neon delete mode 100644 packages/span/phpunit.xml delete mode 100644 packages/span/rector.php delete mode 100644 packages/span/src/Span/Exporter/Exporter.php delete mode 100644 packages/span/src/Span/Exporter/None.php delete mode 100644 packages/span/src/Span/Exporter/Pretty.php delete mode 100644 packages/span/src/Span/Exporter/Sentry.php delete mode 100644 packages/span/src/Span/Exporter/Sentry/Level.php delete mode 100644 packages/span/src/Span/Exporter/SentryField.php delete mode 100644 packages/span/src/Span/Exporter/Stdout.php delete mode 100644 packages/span/src/Span/Level.php delete mode 100644 packages/span/src/Span/Span.php delete mode 100644 packages/span/src/Span/Storage/Auto.php delete mode 100644 packages/span/src/Span/Storage/Coroutine.php delete mode 100644 packages/span/src/Span/Storage/Memory.php delete mode 100644 packages/span/src/Span/Storage/Storage.php delete mode 100644 packages/span/tests/Exporter/NoneTest.php delete mode 100644 packages/span/tests/Exporter/PrettyTest.php delete mode 100644 packages/span/tests/Exporter/SentryTest.php delete mode 100644 packages/span/tests/Exporter/StdoutTest.php delete mode 100644 packages/span/tests/SpanTest.php delete mode 100644 packages/span/tests/Storage/AutoTest.php delete mode 100644 packages/span/tests/Storage/CoroutineTest.php delete mode 100644 packages/span/tests/Storage/MemoryTest.php diff --git a/README.md b/README.md index 270bb74e8..35d8a17e8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The source of truth for the [utopia-php](https://github.com/utopia-php) libraries. Each `packages/` is an independent Composer library; development happens here, and every push is mirrored to its read-only repository (e.g. `utopia-php/http`), so Composer/Packagist distribution is unchanged — mechanics in [docs/distribution.md](docs/distribution.md). -[`utopia-php/compression`](https://github.com/appwrite/appwrite/tree/main/packages/compression), [`utopia-php/pools`](https://github.com/appwrite/appwrite/tree/main/packages/pools), [`utopia-php/smtp`](https://github.com/appwrite/appwrite/tree/main/packages/smtp), [`utopia-php/system`](https://github.com/appwrite/appwrite/tree/main/packages/system) and [`utopia-php/telemetry`](https://github.com/appwrite/appwrite/tree/main/packages/telemetry) are maintained in Appwrite. Their distribution mirrors and Packagist packages remain available. +[`utopia-php/client`](https://github.com/appwrite/appwrite/tree/main/packages/client), [`utopia-php/compression`](https://github.com/appwrite/appwrite/tree/main/packages/compression), [`utopia-php/pools`](https://github.com/appwrite/appwrite/tree/main/packages/pools), [`utopia-php/smtp`](https://github.com/appwrite/appwrite/tree/main/packages/smtp), [`utopia-php/span`](https://github.com/appwrite/appwrite/tree/main/packages/span), [`utopia-php/system`](https://github.com/appwrite/appwrite/tree/main/packages/system) and [`utopia-php/telemetry`](https://github.com/appwrite/appwrite/tree/main/packages/telemetry) are maintained in Appwrite. Their distribution mirrors and Packagist packages remain available. ## Quickstart @@ -46,28 +46,23 @@ To use an unmerged branch from an *external* consumer (e.g. test a fix in Appwri ## Dependency graph -Local siblings only: arrows point at dependencies between the packages in this checkout (`platform --> http` means platform requires http). Requirements on packages maintained elsewhere, such as `utopia-php/telemetry` (required by `cache`, `dns`, `http`, `messaging`, `queue`, `schedule` and `storage`) and `utopia-php/pools` (required by `cache`, `client`, `messaging` and `queue`), are not drawn, so a node without arrows is not necessarily dependency-free. Regenerate with `bin/monorepo graph` after changing a package's requirements — `bin/monorepo validate` (which CI runs on every push) fails while it is stale. +Local siblings only: arrows point at dependencies between the packages in this checkout (`platform --> http` means platform requires http). Requirements on packages maintained elsewhere, including those listed above as maintained in Appwrite, are not drawn, so a node without arrows is not necessarily dependency-free. Regenerate with `bin/monorepo graph` after changing a package's requirements — `bin/monorepo validate` (which CI runs on every push) fails while it is stale. ```mermaid graph TD audit --> validators cache --> circuit-breaker - cdn --> client cli --> servers - client --> span config --> validators console --> validators dns --> validators dns --> domains - domains --> client domains --> validators domains --> cache - fastly --> client http --> di http --> servers http --> validators - messaging --> client platform --> cli platform --> http platform --> queue @@ -77,12 +72,13 @@ graph TD queue --> validators servers --> di servers --> validators - span --> client storage --> validators - storage --> client vcs --> cache auth + cdn + fastly image + messaging nats openapi replication diff --git a/packages/client/.github/workflows/mirror.yml b/packages/client/.github/workflows/mirror.yml deleted file mode 100644 index 0b3dd4782..000000000 --- a/packages/client/.github/workflows/mirror.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Mirror - -on: - pull_request_target: - types: [opened] - issues: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - redirect: - uses: utopia-php/monorepo/.github/workflows/mirror-redirect.yml@main - with: - package: client diff --git a/packages/client/.gitignore b/packages/client/.gitignore deleted file mode 100644 index f57534665..000000000 --- a/packages/client/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/vendor/ -/.phpunit.cache/ -/.phpunit.result.cache -composer.lock diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md deleted file mode 100644 index b73c489cb..000000000 --- a/packages/client/CHANGELOG.md +++ /dev/null @@ -1,34 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -This project follows semantic versioning. - -## [Unreleased] - -### Changed - -- Move `Utopia\Client` to `Utopia\Client\Client` and `Utopia\Psr18\StreamingClientInterface` to `Utopia\Client\Psr18\StreamingClientInterface`, so the package declares a single `Utopia\Client\` prefix. The old names remain as aliases until the next major release. -- Require `utopia-php/psr7` `^0.2.1` so `Request\Factory::multipart` always sets `Content-Type` with the body boundary. - -### Added - -- Initial PSR-18 HTTP client wrapper: `Utopia\Client`. -- Immutable client defaults for headers, base URI, basic auth, and bearer auth. -- cURL adapter for regular PHP runtimes. -- Swoole coroutine adapter for coroutine runtimes. -- Dependency on `utopia-php/psr7` for `Utopia\Psr7` PSR-7 messages and PSR-17 factories. -- End-to-end response streaming via `stream()`, delivering the body to a sink chunk-by-chunk with bounded memory. -- `Utopia\Psr18\StreamingClientInterface` for the streaming counterpart to PSR-18; the `Adapter` interface composes it with `Psr\Http\Client\ClientInterface`, and `Utopia\Client` implements `Adapter`. -- Bounded-memory request uploads on the cURL adapter, streaming the body through a read callback; `Stream\Factory::createStreamFromFile()` opens files lazily and `Stream::fromResource()` wraps a resource without copying it. -- Bounded-memory multipart file uploads on both adapters via lazy `Part::file()` and the `Multipart\Body` stream: cURL streams the serialised body from disk, while Swoole streams each file with native `addFile()` (zero-copy `sendfile()`). -- Typed PSR-18 exception hierarchy (`NetworkException`/`RequestException` and their subtypes) thrown by both adapters. -- Immutable timeout helpers for total and connection timeouts. -- Portable TLS configuration helpers — `withSslVerification()`, `withCustomCA()`, `withCertificate()`, `withMinTlsVersion()` — and the `Utopia\Client\Tls` enum. -- `withConnectionReuse()` to keep the underlying connection alive and reuse it across requests to the same origin, on both adapters (cURL persists and resets one handle; Swoole keeps a kept-alive coroutine client). -- `withFollowRedirects()` to follow HTTP `Location` redirects on both adapters (cURL maps to `CURLOPT_FOLLOWLOCATION`; Swoole follows `Location` in the adapter because the coroutine client has no native equivalent). Off by default, so a 3xx response is returned with its `Location` header. Constructor `CURLOPT_FOLLOWLOCATION` / `follow_location` values do not override the helper. Relative `Location` values use RFC 3986 resolution. Cross-origin hops and HTTPS to HTTP downgrades strip `Authorization`, `Cookie`, `Cookie2`, and `Proxy-Authorization`. Swoole streams the final hop through its write callback rather than buffering it first. -- `Utopia\Client\Pool` to borrow a reused client from a `utopia-php/pools` pool per request and reclaim it afterwards, sharing a bounded set of connections across concurrent callers. -- `Utopia\Client\Decorator\Retry` decorator with a pluggable `Strategy` and a default best-practice `Backoff` strategy (idempotent methods only, transient transport failures and `429`/`502`/`503`/`504`, exponential backoff with jitter, `Retry-After` honoured). -- Opt-in W3C Trace Context propagation via `withTracePropagation()` (off by default): forwards the active `utopia-php/span` trace downstream as a `traceparent` header. -- `Utopia\Client\Decorator` base class for composing adapter decorators. -- PHP 8.4+ tooling with Pint, PHPStan level 10, Rector, PHPUnit, Composer audit, and GitHub Actions CI. diff --git a/packages/client/LICENSE.md b/packages/client/LICENSE.md deleted file mode 100644 index 252e89f85..000000000 --- a/packages/client/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013 Eldad Fux - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/client/README.md b/packages/client/README.md deleted file mode 100644 index 7615bbc00..000000000 --- a/packages/client/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Utopia Client - -> [!IMPORTANT] -> This repository is a read-only mirror of the [utopia-php monorepo](https://github.com/utopia-php/monorepo). Development happens in [`packages/client`](https://github.com/utopia-php/monorepo/tree/main/packages/client) — please open issues and pull requests there. - -A small PSR-18 HTTP client for PHP 8.4+. cURL and Swoole coroutine transports, using `utopia-php/psr7` for PSR-7 messages and request/response helpers. - -## Install - -```bash -composer require utopia-php/client -``` - -Use `ext-curl` for the cURL adapter and `ext-swoole` for the Swoole coroutine adapter. - -## Quick start - -```php -json(Method::POST, 'https://example.com/users', [ - 'name' => 'Ada', -]); - -$response = $client->sendRequest($request); - -echo $response->getStatusCode(); -echo $response->json()['name']; -``` - -`Utopia\Client\Client` implements `Psr\Http\Client\ClientInterface`, so it works anywhere a PSR-18 client is expected. HTTP/1.1 is used by default and redirects are not followed unless you call `withFollowRedirects()`, so you receive exactly the response the server returned. - -The concrete `Utopia\Psr7` messages and factories are provided by the `utopia-php/psr7` dependency. - -## Configure the client - -Defaults are immutable — each `with*()` returns a configured clone, and a default never overrides a header already set on the request. - -```php -withBaseUri('https://api.example.com/v1') // relative request URIs resolve against this - ->withHeaders([ - Header::ACCEPT => ContentType::JSON, - Header::USER_AGENT => 'Acme API Client', - ]) - ->withBearerAuth('token'); // or ->withBasicAuth('user', 'pass') -``` - -## Build requests - -```php -json(Method::POST, 'https://api.example.com/users', [ - 'name' => 'Ada', -]); - -$form = $requestFactory->form(Method::POST, 'https://api.example.com/sessions', [ - 'email' => 'ada@example.com', - 'password' => 'secret', -]); - -$query = $requestFactory->query(Method::GET, 'https://api.example.com/users', [ - 'page' => 2, - 'search' => 'Ada Lovelace', -]); - -$upload = $requestFactory->multipart(Method::POST, 'https://api.example.com/uploads', [ - 'name' => 'Ada', - 'avatar' => Part::file('avatar', '/tmp/avatar.png', 'avatar.png', 'image/png'), -]); - -$xml = $requestFactory->xml(Method::POST, 'https://api.example.com/users', 'Ada'); - -$text = $requestFactory->text(Method::POST, 'https://api.example.com/notes', 'Hello, Ada'); -``` - -Pass a 4th `$headers` array to any factory method to override defaults, such as a custom `Content-Type` or `Accept`. - -## Decode responses - -```php -contentType(); // media type without parameters, e.g. "application/json" -$data = $response->json(); -$xml = $response->xml(); -$text = $response->text(); -$form = $response->form(); -$parts = $response->multipart(); - -foreach ($parts as $part) { - $name = $part->name(); - $filename = $part->filename(); - $contentType = $part->contentType(); - $body = $part->body(); -} -``` - -HTTP `4xx` and `5xx` responses are returned, not thrown, as required by PSR-18. - -## More - -- [Streaming](docs/streaming.md) — consume large downloads (SSE, LLM token streams) and upload large files, both with bounded memory. -- [Retries](docs/retries.md) — the `Retry` decorator and its configurable best-practice backoff strategy. -- [Pooling](docs/pooling.md) — share a bounded set of reused connections across concurrent callers with `Pool`. -- [Configuration](docs/configuration.md) — timeouts, TLS, connection reuse, native cURL options, and the Swoole coroutine adapter. -- [Error handling](docs/error-handling.md) — the PSR-18 exception hierarchy the adapters throw. -- [Development](docs/development.md) — running the test suite and checks. diff --git a/packages/client/composer.json b/packages/client/composer.json deleted file mode 100644 index 6eceb2bb5..000000000 --- a/packages/client/composer.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "utopia-php/client", - "description": "A lightweight PSR-18 HTTP client with cURL and Swoole coroutine backends", - "type": "library", - "keywords": [ - "php", - "http", - "client", - "psr-18", - "curl", - "swoole", - "utopia" - ], - "license": "MIT", - "autoload": { - "psr-4": { - "Utopia\\Client\\": "src/" - }, - "files": [ - "src/compat.php" - ] - }, - "autoload-dev": { - "psr-4": { - "Utopia\\Client\\Tests\\": "tests/" - } - }, - "scripts": { - "test": "phpunit --configuration phpunit.xml" - }, - "require": { - "php": ">=8.5", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "utopia-php/pools": "^2.0", - "utopia-php/psr7": "^0.2.1", - "utopia-php/span": "^1.1 || ^3.0 || ^4.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." - }, - "require-dev": { - "swoole/ide-helper": "^6.0" - }, - "config": { - "platform": { - "php": "8.4" - }, - "allow-plugins": { - "php-http/discovery": true, - "tbachert/spi": true - } - } -} diff --git a/packages/client/docs/configuration.md b/packages/client/docs/configuration.md deleted file mode 100644 index 1f7e9cd16..000000000 --- a/packages/client/docs/configuration.md +++ /dev/null @@ -1,171 +0,0 @@ -# Configuration - -Timeout and TLS helpers are immutable and portable across both adapters — each -`with*()` returns a configured clone and maps to the right native cURL option or -Swoole setting for you. For anything the helpers don't cover, pass native options -to the adapter constructor. - -## Timeouts - -Timeout values are seconds. - -```php -withTimeout(5) - ->withConnectTimeout(1); -``` - -The cURL adapter maps these to `CURLOPT_TIMEOUT_MS` and `CURLOPT_CONNECTTIMEOUT_MS`. The Swoole adapter maps them to `timeout` and `connect_timeout`. - -## TLS - -```php -withCustomCA('/etc/ssl/internal-ca.pem') // trust a private CA bundle - ->withCertificate('/etc/ssl/client.pem', '/etc/ssl/client.key') // mutual TLS (optional passphrase as 3rd arg) - ->withMinTlsVersion(Tls::V1_2); // refuse anything older than TLS 1.2 -``` - -Certificate-chain and hostname verification are on by default. `withSslVerification(false)` disables certificate verification entirely — it is insecure and intended only for local development against self-signed servers. To trust a self-signed certificate *while keeping verification on*, point `withCustomCA()` at it instead. - -The Swoole adapter derives `ssl_host_name` from each HTTPS request's URI, including redirect hops and reused clients. Neither a custom `Host` header nor a native `ssl_host_name` option overrides the certificate identity being checked. - -The Swoole adapter rejects verified HTTPS requests to IP-literal URIs with `TlsException` before sending the request. Swoole's native hostname checker does not correctly distinguish DNS names from IP address certificate identities. Use a matching DNS hostname or the cURL adapter for verified IP endpoints; do not disable verification to work around this limitation. - -```php -withSslVerification(false); // insecure: disables certificate checks -``` - -## Connection reuse - -Off by default, each request opens a fresh connection. `withConnectionReuse()` -keeps the underlying connection alive and reuses it for further requests to the -same origin, so the TCP/TLS handshake is paid once. - -```php -withConnectionReuse(); // or ->withConnectionReuse(false) -``` - -It maps to the right transport primitive on each adapter: the cURL adapter keeps -a single persisted handle (reset between requests, connection cache preserved), -and the Swoole adapter keeps a kept-alive coroutine client. A connection is bound -to its origin, so a request to a different host transparently gets a new one. - -Reuse is most useful when one adapter sends many requests to the same host — see -[pooling](pooling.md) for spreading a bounded set of reused connections across -concurrent callers. - -## Redirects - -Off by default, a 3xx response is returned as-is with its `Location` header. -`withFollowRedirects()` follows `Location` until the final non-redirect response. -Pass `false` to turn following off again. - -```php -withFollowRedirects(); // or ->withFollowRedirects(false) -``` - -The cURL adapter maps this to `CURLOPT_FOLLOWLOCATION`. The Swoole coroutine HTTP -client has no follow-redirects setting, so the adapter issues each hop itself. -Relative `Location` values are resolved with RFC 3986 (including `.` / `..`). -Same-origin hops keep `Authorization` and `Cookie`; a change of origin or an -HTTPS to HTTP downgrade strips `Authorization`, `Cookie`, `Cookie2`, and -`Proxy-Authorization`. Constructor `CURLOPT_FOLLOWLOCATION` or `follow_location` -values do not override the helper. - -## Compression - -Both adapters negotiate response compression automatically: each request -advertises the codecs its transport can decode via `Accept-Encoding`, and a -compressed response is decoded transparently before it reaches you, so -`$response->getBody()` is always plaintext. The now-stale `Content-Encoding` and -`Content-Length` headers are dropped from the decoded response. - -The cURL adapter advertises and decodes every codec its libcurl build supports -(typically gzip, deflate, br, and zstd) for both buffered and streamed responses. -The Swoole adapter advertises and decodes gzip, deflate, and br for buffered -responses; when streaming it requests `identity` instead, because Swoole hands -streamed bytes to the sink undecoded — so a streamed download is left -uncompressed rather than delivered as bytes you would have to inflate yourself. - -Set your own `Accept-Encoding` header on a request to take over negotiation — the -adapter then leaves the request and response bytes exactly as they are: - -```php -withHeader(Header::ACCEPT_ENCODING, 'identity'); -``` - -## Native cURL options - -Pass native cURL options with the `options` constructor argument. Options override adapter defaults when keys overlap. - -```php - 5_000, - CURLOPT_CONNECTTIMEOUT_MS => 1_000, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, -]); -``` - -The adapter defaults include: - -- `CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1` - -`CURLOPT_FOLLOWLOCATION` is owned by [`withFollowRedirects()`](#redirects) and is -not taken from constructor options. - -## Swoole coroutine adapter - -The Swoole adapter must run inside a coroutine. Pass native client settings with the `settings` constructor argument. - -```php - 5, - 'connect_timeout' => 1, - ]), - ); - - $response = $client->sendRequest( - $requestFactory->query(Method::GET, 'https://example.com', [ - 'ping' => '1', - ]), - ); - - echo $response->getStatusCode(); -}); -``` diff --git a/packages/client/docs/development.md b/packages/client/docs/development.md deleted file mode 100644 index 38297f8b1..000000000 --- a/packages/client/docs/development.md +++ /dev/null @@ -1,14 +0,0 @@ -# Development - -The repository includes a local copy of the PSR-18 specification under `docs/`, with translated coverage requirements in [testing-requirements.md](testing-requirements.md). - -```bash -composer install -composer audit -composer format:check -composer refactor:check -composer analyze -composer test -``` - -CI runs the same checks on pull requests and pushes to `main` for PHP 8.4 and 8.5. diff --git a/packages/client/docs/error-handling.md b/packages/client/docs/error-handling.md deleted file mode 100644 index f82d86b07..000000000 --- a/packages/client/docs/error-handling.md +++ /dev/null @@ -1,120 +0,0 @@ -# Error Handling - -A request has two possible outcomes: it returns a response, or it throws. An HTTP -`4xx` or `5xx` is a **response**, not an exception — as required by PSR-18, those -are returned so you inspect `getStatusCode()` yourself. Exceptions are reserved for -failures where no usable response was produced. - -## Two kinds of failure - -Every exception implements `Psr\Http\Client\ClientExceptionInterface` and falls into -one of two branches. The branch tells you whether retrying could help: - -- **`NetworkExceptionInterface`** — the transport failed and the request never - completed (DNS, connect, timeout, reset, TLS). These are often transient, so - retrying an *idempotent* request with backoff is reasonable. -- **`RequestExceptionInterface`** — the request or the response is fundamentally - invalid (non-absolute URI, missing extension, malformed response). Retrying the - same request will not help. - -Catch the interfaces, not the concrete classes, when all you need is that decision: - -```php -sendRequest($request); - break; - } catch (NetworkExceptionInterface $error) { - if ($attempt >= 3) { - throw $error; - } - // Transport failure — back off and retry. Only do this for idempotent - // requests; a non-idempotent request (e.g. POST) may have been received. - } catch (RequestExceptionInterface $error) { - // The request or response is invalid — surface it; retrying is pointless. - throw $error; - } -} -``` - -## Hierarchy - -``` -ClientExceptionInterface -├── NetworkExceptionInterface — transport failed; the request did not complete -│ └── NetworkException -│ ├── DnsException host could not be resolved -│ ├── TimeoutException connect or transfer timed out -│ ├── ProtocolException HTTP framing / HTTP-2/3 transport error -│ ├── ProxyException proxy or SOCKS handshake failed -│ └── ConnectionException refused, reset, unreachable, or broken -│ └── TlsException TLS handshake or certificate failure -└── RequestExceptionInterface — the request or response is invalid; retrying will not help - └── RequestException - ├── InvalidUriException URI is not absolute or uses an unsupported scheme - ├── AdapterPreconditionException runtime precondition unmet (extension missing, not in a coroutine) - ├── AdapterInitializationException the transport could not be initialized - └── InvalidResponseException the server's response was malformed -``` - -`TlsException` is a `ConnectionException`, which is a `NetworkException`. **Order -matters** when catching concrete classes: list the most specific first, or a broader -`catch` will shadow it. - -```php -sendRequest($request); -} catch (TimeoutException $error) { - // Connect or transfer timed out. -} catch (DnsException $error) { - // Host could not be resolved. -} catch (TlsException $error) { // before ConnectionException — it is a subclass - // TLS handshake or certificate failure. -} catch (ProxyException $error) { - // Proxy or SOCKS handshake failed. -} catch (ProtocolException $error) { - // HTTP framing / HTTP-2/3 transport error. -} catch (ConnectionException $error) { - // Connection refused, reset, unreachable, or broken. -} catch (InvalidUriException $error) { - // The request URI is not absolute or uses an unsupported scheme. -} catch (InvalidResponseException $error) { - // The server's response was malformed. -} -``` - -## What an exception carries - -Both branches expose the request that failed via `getRequest()`, alongside the -standard SPL accessors: - -```php -sendRequest($request); -} catch (NetworkExceptionInterface $error) { - $error->getRequest(); // the RequestInterface that failed - $error->getMessage(); // human-readable description - $error->getCode(); // native cURL errno / Swoole error code - $error->getPrevious(); // underlying throwable, if any -} -``` diff --git a/packages/client/docs/pooling.md b/packages/client/docs/pooling.md deleted file mode 100644 index c6dc3e522..000000000 --- a/packages/client/docs/pooling.md +++ /dev/null @@ -1,84 +0,0 @@ -# Pooling - -`Utopia\Client\Pool` borrows a client from a [utopia-php/pools](https://github.com/utopia-php/pools) -pool for the duration of each request and reclaims it when the request completes, -so concurrent callers share a bounded set of connections instead of each opening -their own. - -```bash -composer require utopia-php/pools -``` - -```php - new Client((new CurlAdapter())->withConnectionReuse()), -)); - -$response = $pool->sendRequest($request); -$pool->stream($request, $sink); -``` - -Pair the pooled adapters with [`withConnectionReuse()`](configuration.md#connection-reuse): -without it the pool still bounds concurrency, but each borrow dials a fresh -connection, so the handshake savings are lost. - -Because `Utopia\Client\Client` implements `Adapter` (and therefore both -`Psr\Http\Client\ClientInterface` and `Utopia\Client\Psr18\StreamingClientInterface`), -the `init` callback can return a fully configured client — base URI, default -headers, auth, retries — so every pooled connection carries the same setup: - -```php - (new Client(new Retry((new CurlAdapter())->withConnectionReuse()))) - ->withBaseUri('https://api.example.com') - ->withBearerAuth($token), -``` - -## Swoole - -Use the `Utopia\Pools\Adapter\Swoole` pool adapter in a coroutine runtime so each -coroutine borrows a distinct connection. Run the pool inside `Coroutine\run()`, -and have `init` return a Swoole adapter with reuse enabled. - -```php - new Client((new SwooleAdapter())->withConnectionReuse()), -)); -``` - -## Notes - -- `Pool` implements `ClientInterface` and `StreamingClientInterface`, not `Adapter` - — it has no `with*()` helpers, since pooling load-balances across many - connections. Configure the connections in `init` instead. -- Connections are created lazily and on demand: a `size: 10` pool under sequential - load reuses a single connection; it only opens more when concurrent callers hold - connections at the same time. -- Reuse is per origin. A pool whose requests span many hosts re-dials on each host - change, so pooling pays off most against a single upstream. -- A stale pooled connection (dropped by the server while idle) is handled by the - transport: the cURL handle and the Swoole client both detect a dead socket and - reconnect on the next request. diff --git a/packages/client/docs/psr/PSR-18-http-client.md b/packages/client/docs/psr/PSR-18-http-client.md deleted file mode 100644 index af37997bd..000000000 --- a/packages/client/docs/psr/PSR-18-http-client.md +++ /dev/null @@ -1,161 +0,0 @@ -HTTP Client -=========== - -This document describes a common interface for sending HTTP requests and receiving HTTP responses. - -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", -"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be -interpreted as described in [RFC 2119](http://tools.ietf.org/html/rfc2119). - -## Goal - -The goal of this PSR is to allow developers to create libraries decoupled from HTTP client -implementations. This will make libraries more reusable as it reduces the number of -dependencies and lowers the likelihood of version conflicts. - -A second goal is that HTTP clients can be replaced as per the -[Liskov substitution principle][Liskov]. This means that all clients MUST behave in the -same way when sending a request. - -## Definitions - -* **Client** - A Client is a library that implements this specification for the purposes of -sending PSR-7-compatible HTTP Request messages and returning a PSR-7-compatible HTTP Response message to a Calling library. -* **Calling Library** - A Calling Library is any code that makes use of a Client. It does not implement -this specification's interfaces but consumes an object that implements them (a Client). - -## Client - -A Client is an object implementing `ClientInterface`. - -A Client MAY: - -* Choose to send an altered HTTP request from the one it was provided. For example, it could -compress an outgoing message body. -* Choose to alter a received HTTP response before returning it to the calling library. For example, it could -decompress an incoming message body. - -If a Client chooses to alter either the HTTP request or HTTP response, it MUST ensure that the -object remains internally consistent. For example, if a Client chooses to decompress the message -body then it MUST also remove the `Content-Encoding` header and adjust the `Content-Length` header. - -Note that as a result, since [PSR-7 objects are immutable](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-7-http-message-meta.md#why-value-objects), -the Calling Library MUST NOT assume that the object passed to `ClientInterface::sendRequest()` will be the same PHP object -that is actually sent. For example, the Request object that is returned by an exception MAY be a different object than -the one passed to `sendRequest()`, so comparison by reference (===) is not possible. - -A Client MUST: - -* Reassemble a multi-step HTTP 1xx response itself so that what is returned to the Calling Library is a valid HTTP response -of status code 200 or higher. - -## Error handling - -A Client MUST NOT treat a well-formed HTTP request or HTTP response as an error condition. For example, response -status codes in the 400 and 500 range MUST NOT cause an exception and MUST be returned to the Calling Library as normal. - -A Client MUST throw an instance of `Psr\Http\Client\ClientExceptionInterface` if and only if it is unable to send -the HTTP request at all or if the HTTP response could not be parsed into a PSR-7 response object. - -If a request cannot be sent because the request message is not a well-formed HTTP request or is missing some critical -piece of information (such as a Host or Method), the Client MUST throw an instance of `Psr\Http\Client\RequestExceptionInterface`. - -If the request cannot be sent due to a network failure of any kind, including a timeout, the Client MUST throw an -instance of `Psr\Http\Client\NetworkExceptionInterface`. - -Clients MAY throw more specific exceptions than those defined here (a `TimeOutException` or `HostNotFoundException` for -example), provided they implement the appropriate interface defined above. - -## Interfaces - -### ClientInterface - -```php -namespace Psr\Http\Client; - -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; - -interface ClientInterface -{ - /** - * Sends a PSR-7 request and returns a PSR-7 response. - * - * @param RequestInterface $request - * - * @return ResponseInterface - * - * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens while processing the request. - */ - public function sendRequest(RequestInterface $request): ResponseInterface; -} -``` - -### ClientExceptionInterface - -```php -namespace Psr\Http\Client; - -/** - * Every HTTP client related exception MUST implement this interface. - */ -interface ClientExceptionInterface extends \Throwable -{ -} -``` - -### RequestExceptionInterface - -```php -namespace Psr\Http\Client; - -use Psr\Http\Message\RequestInterface; - -/** - * Exception for when a request failed. - * - * Examples: - * - Request is invalid (e.g. method is missing) - * - Runtime request errors (e.g. the body stream is not seekable) - */ -interface RequestExceptionInterface extends ClientExceptionInterface -{ - /** - * Returns the request. - * - * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() - * - * @return RequestInterface - */ - public function getRequest(): RequestInterface; -} -``` - -### NetworkExceptionInterface - -```php -namespace Psr\Http\Client; - -use Psr\Http\Message\RequestInterface; - -/** - * Thrown when the request cannot be completed because of network issues. - * - * There is no response object as this exception is thrown when no response has been received. - * - * Example: the target host name can not be resolved or the connection failed. - */ -interface NetworkExceptionInterface extends ClientExceptionInterface -{ - /** - * Returns the request. - * - * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() - * - * @return RequestInterface - */ - public function getRequest(): RequestInterface; -} -``` - -[Liskov]: https://en.wikipedia.org/wiki/Liskov_substitution_principle diff --git a/packages/client/docs/retries.md b/packages/client/docs/retries.md deleted file mode 100644 index 0c33e0510..000000000 --- a/packages/client/docs/retries.md +++ /dev/null @@ -1,108 +0,0 @@ -# Retries - -`Utopia\Client\Decorator\Retry` is an adapter that decorates another adapter and -retries failed requests. Because it is itself an `Adapter`, it composes — wrap it in -`Client` (and stack it with other decorators) wherever a transport goes. - -```php - Coroutine::sleep($seconds), -); -``` - -## Writing your own decorator - -`Retry` extends `Utopia\Client\Decorator`, the base for any adapter that wraps -another. It forwards every configuration helper to the inner adapter and delegates -sending; a subclass overrides only `sendRequest()` / `stream()`. Because each -decorator is itself an `Adapter`, they stack in any order — for example -`new Client(new Retry(new SomeOtherDecorator(new CurlAdapter())))`. diff --git a/packages/client/docs/streaming.md b/packages/client/docs/streaming.md deleted file mode 100644 index 3cf4d2648..000000000 --- a/packages/client/docs/streaming.md +++ /dev/null @@ -1,102 +0,0 @@ -# Streaming - -## Responses - -`stream()` delivers the response body to a sink callback chunk-by-chunk as -it arrives, so large downloads, Server-Sent Events, and LLM token streams are -consumed with bounded memory — the whole body is never held at once. It returns a -response carrying the status and headers; the body is empty because the body was -handed to the sink. Both adapters support it. - -```php -stream($request, function (string $chunk): void { - echo $chunk; -}); - -echo $response->getStatusCode(); -``` - -The sink runs as each chunk arrives, which means it also applies backpressure: the -transfer does not read ahead while the sink is still working. To stop early, throw -from the sink. - -```php -stream($request, function (string $chunk) use (&$buffer): void { - $buffer .= $chunk; - - while (($newline = strpos($buffer, "\n")) !== false) { - $line = substr($buffer, 0, $newline); - $buffer = substr($buffer, $newline + 1); - // handle $line - } -}); -``` - -Notes: - -- Use `sendRequest()` for normal requests — it buffers the body and returns a - fully decodable response (`->json()`, `->form()`, `->multipart()`). -- `stream()` returns only once the stream ends. For an unbounded stream - (e.g. SSE), set the transport timeout to no-limit (`CURLOPT_TIMEOUT_MS => 0` on - cURL, `timeout => -1` on Swoole) and stop by throwing from the sink. -- The Swoole adapter must run inside a coroutine, like `sendRequest()`. - -## Requests - -Attach a file-backed body and the cURL adapter uploads it chunk-by-chunk through a -read callback, so memory stays bounded no matter how large the file is — it is -never read into a string. `createStreamFromFile()` opens the file lazily, so even -building the request costs nothing. - -```php -createRequest(Method::POST, 'https://example.com/upload') - ->withHeader(Header::CONTENT_TYPE, ContentType::OCTET_STREAM) - ->withBody($streamFactory->createStreamFromFile('/path/to/large.bin')); - -$client->sendRequest($request); -``` - -The body's `getSize()` sets `Content-Length` when known; an unsized stream (e.g. a -pipe) is sent with chunked transfer encoding. Seekable bodies are rewound before -each attempt, so streamed uploads remain safe to retry. - -### Multipart file uploads - -`Part::file()` references a file by path and reads it lazily, so multipart uploads -stay bounded too — on **both** adapters: - -```php -multipart(Method::POST, 'https://example.com/upload', [ - 'name' => 'Ada', - 'file' => Part::file('file', '/path/to/large.bin', 'large.bin', ContentType::OCTET_STREAM), -]); - -$client->sendRequest($request); -``` - -The cURL adapter streams the serialised multipart body straight from disk through -its read callback. The Swoole adapter sends each file with its native `addFile()`, -which streams from disk with zero-copy `sendfile()`. - -Notes: - -- A raw (non-multipart) request body streams bounded on the cURL adapter; the - Swoole coroutine client has no raw-body streaming, so it buffers raw bodies — - use a multipart `Part::file()` for bounded uploads on Swoole. -- Swoole's native upload can't represent custom per-part headers, repeated field - names, or empty files; a multipart body using any of those is buffered instead - (it is still sent correctly, just not streamed). diff --git a/packages/client/docs/testing-requirements.md b/packages/client/docs/testing-requirements.md deleted file mode 100644 index 1350d933d..000000000 --- a/packages/client/docs/testing-requirements.md +++ /dev/null @@ -1,33 +0,0 @@ -# Testing Requirements - -These requirements translate the vendored FIG specification and production-readiness concerns into local test coverage. - -Source specs: - -- `docs/psr/PSR-18-http-client.md` - -## PSR-18 client coverage - -- Clients return 4xx/5xx responses without throwing. -- Invalid requests throw `RequestExceptionInterface`. -- Network failures, including timeouts, throw `NetworkExceptionInterface`. -- Clients collapse interim 1xx response headers and expose only the final response. - -## Timeout coverage - -- `Utopia\Client\Client` timeout helpers are immutable. -- cURL adapter maps timeout seconds to `CURLOPT_TIMEOUT_MS`. -- cURL adapter maps connect timeout seconds to `CURLOPT_CONNECTTIMEOUT_MS`. -- Swoole adapter maps timeout seconds to `timeout`. -- Swoole adapter maps connect timeout seconds to `connect_timeout`. -- Invalid timeout values throw `ValueError`. - -## Redirect coverage - -- Redirects are not followed by default; a 3xx response is returned with its `Location` header. -- `withFollowRedirects()` follows `Location` to the final non-redirect response on both adapters. -- Relative `Location` values are resolved with RFC 3986, including `.` / `..`. -- Same-origin redirects keep `Authorization`; cross-origin and HTTPS to HTTP hops strip sensitive headers. -- Streaming after a redirect delivers the final body to the sink without buffering it first. -- Fifty hops succeed; the fifty-first throws `ProtocolException`. -- `Utopia\Client\Client::withFollowRedirects()` forwards onto the adapter. diff --git a/packages/client/phpstan.neon b/packages/client/phpstan.neon deleted file mode 100644 index 340c49daa..000000000 --- a/packages/client/phpstan.neon +++ /dev/null @@ -1,6 +0,0 @@ -includes: - - ../../phpstan.neon - -parameters: - level: 10 - phpVersion: 80500 diff --git a/packages/client/phpunit.xml b/packages/client/phpunit.xml deleted file mode 100644 index 2c671ba37..000000000 --- a/packages/client/phpunit.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - ./tests - - - - - ./src - - - diff --git a/packages/client/rector.php b/packages/client/rector.php deleted file mode 100644 index 67ca942f8..000000000 --- a/packages/client/rector.php +++ /dev/null @@ -1,9 +0,0 @@ -withPreparedSets( - codingStyle: true, - instanceOf: true, - privatization: true, -); diff --git a/packages/client/src/Adapter.php b/packages/client/src/Adapter.php deleted file mode 100644 index 5f7d48f09..000000000 --- a/packages/client/src/Adapter.php +++ /dev/null @@ -1,41 +0,0 @@ - $options - */ - public function __construct( - ResponseFactoryInterface $responseFactory = new Response\Factory(), - StreamFactoryInterface $streamFactory = new Stream\Factory(), - private array $options = [], - ) { - $this->options += [ - \CURLOPT_CONNECTTIMEOUT_MS => $this->milliseconds(self::DEFAULT_CONNECT_TIMEOUT), - \CURLOPT_TIMEOUT_MS => $this->milliseconds(self::DEFAULT_TIMEOUT), - ]; - - $this->responseBuilder = new ResponseBuilder($responseFactory, $streamFactory); - } - - public function __clone(): void - { - // Clones get their own handle and connection cache. - $this->handle = null; - } - - public function withTimeout(float $seconds): static - { - $clone = clone $this; - $clone->options[\CURLOPT_TIMEOUT_MS] = $this->milliseconds($seconds); - - return $clone; - } - - public function withConnectTimeout(float $seconds): static - { - $clone = clone $this; - $clone->options[\CURLOPT_CONNECTTIMEOUT_MS] = $this->milliseconds($seconds); - - return $clone; - } - - public function withSslVerification(bool $enabled = true): static - { - $clone = clone $this; - $clone->options[\CURLOPT_SSL_VERIFYPEER] = $enabled; - $clone->options[\CURLOPT_SSL_VERIFYHOST] = $enabled ? 2 : 0; - - return $clone; - } - - public function withCustomCA(string $path): static - { - $clone = clone $this; - $clone->options[\CURLOPT_CAINFO] = $path; - - return $clone; - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - $clone = clone $this; - $clone->options[\CURLOPT_SSLCERT] = $certPath; - $clone->options[\CURLOPT_SSLKEY] = $keyPath; - - if ($passphrase !== null) { - $clone->options[\CURLOPT_KEYPASSWD] = $passphrase; - } - - return $clone; - } - - public function withMinTlsVersion(Tls $version): static - { - $clone = clone $this; - $clone->options[\CURLOPT_SSLVERSION] = match ($version) { - Tls::V1_0 => \CURL_SSLVERSION_TLSv1_0, - Tls::V1_1 => \CURL_SSLVERSION_TLSv1_1, - Tls::V1_2 => \CURL_SSLVERSION_TLSv1_2, - Tls::V1_3 => \CURL_SSLVERSION_TLSv1_3, - }; - - return $clone; - } - - public function withConnectionReuse(bool $enabled = true): static - { - $clone = clone $this; - $clone->reuseConnections = $enabled; - - return $clone; - } - - public function withFollowRedirects(bool $enabled = true): static - { - $clone = clone $this; - $clone->followRedirects = $enabled; - - return $clone; - } - - /** - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - $body = ''; - - $parsed = $this->transfer($request, static function (string $chunk) use (&$body): void { - $body .= $chunk; - }); - - return $this->responseBuilder->build( - $parsed['status'], - $parsed['reason'], - $parsed['headers'], - $body, - $parsed['protocol'], - ); - } - - /** - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface - */ - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - $parsed = $this->transfer($request, $sink); - - return $this->responseBuilder->build( - $parsed['status'], - $parsed['reason'], - $parsed['headers'], - '', - $parsed['protocol'], - ); - } - - /** - * Run the transfer, forwarding each body chunk to $sink, and return the - * parsed status line and headers. cURL invokes the write callback as data - * arrives, so a streaming $sink sees the body chunk-by-chunk and the body - * is never fully held in memory. - * - * @param callable(string): void $sink - * - * @return array{protocol: string, status: int, reason: string, headers: array>} - * - * @throws ClientExceptionInterface - */ - private function transfer(RequestInterface $request, callable $sink): array - { - if (!\extension_loaded('curl')) { - throw new AdapterPreconditionException($request, 'The curl extension is required.'); - } - - $uri = $request->getUri(); - - if (!\in_array($uri->getScheme(), ['http', 'https'], true) || $uri->getHost() === '') { - throw new InvalidUriException($request, 'Requests must use an absolute URI.'); - } - - // Skipped when the caller negotiates encoding themselves. - $decompress = !$request->hasHeader(Header::ACCEPT_ENCODING); - - $headers = ''; - $handle = $this->handle($request); - $options = $this->options($request, $headers, $sink, $decompress); - - try { - if (curl_setopt_array($handle, $options) === false) { - throw new InvalidArgumentException('Unable to configure curl.'); - } - - $result = curl_exec($handle); - } catch (ValueError $valueError) { - throw new InvalidArgumentException($valueError->getMessage(), 0, $valueError); - } - - if ($result === false) { - $message = curl_error($handle); - $code = curl_errno($handle); - - throw $this->networkException($request, $message === '' ? 'Curl request failed.' : $message, $code); - } - - if (!preg_match("/\r\n\r\n|\n\n|\r\r/", $headers)) { - throw new ConnectionException($request, 'Connection closed before a complete HTTP response was received.'); - } - - $parsed = $this->parseHeaderBlock($headers); - - if ($parsed['status'] < 100 || $parsed['status'] > 599) { - throw new InvalidResponseException($request, 'Received an invalid HTTP response.'); - } - - if ($decompress) { - $parsed['headers'] = $this->decoded($parsed['headers']); - } - - return $parsed; - } - - /** - * curl_reset() clears per-request options but keeps the handle's connection - * cache, so a kept handle reuses the socket; a fresh handle starts anew. - * - * @throws ClientExceptionInterface - */ - private function handle(RequestInterface $request): CurlHandle - { - if ($this->reuseConnections && $this->handle instanceof CurlHandle) { - curl_reset($this->handle); - - return $this->handle; - } - - $handle = curl_init(); - - if (!$handle instanceof CurlHandle) { - throw new AdapterInitializationException($request, 'Unable to initialize curl.'); - } - - if ($this->reuseConnections) { - $this->handle = $handle; - } - - return $handle; - } - - /** - * @param-out string $headers - * @param callable(string): void $sink - * - * @return array - */ - private function options(RequestInterface $request, string &$headers, callable $sink, bool $decompress): array - { - $options = [ - \CURLOPT_URL => (string) $request->getUri(), - \CURLOPT_CUSTOMREQUEST => $request->getMethod(), - \CURLOPT_NOBODY => $request->getMethod() === Method::HEAD, - \CURLOPT_HEADER => false, - \CURLOPT_HTTPHEADER => $this->headers($request), - \CURLOPT_HTTP_VERSION => \CURL_HTTP_VERSION_1_1, - \CURLOPT_HEADERFUNCTION => static function (CurlHandle $handle, string $line) use (&$headers): int { - unset($handle); - $headers .= $line; - - return \strlen($line); - }, - \CURLOPT_RETURNTRANSFER => false, - \CURLOPT_WRITEFUNCTION => static function (CurlHandle $handle, string $chunk) use ($sink): int { - unset($handle); - $sink($chunk); - - return \strlen($chunk); - }, - ]; - - $body = $request->getBody(); - $size = $body->getSize(); - - // Stream the body through a read callback so it is never fully held in - // memory. cURL pulls it in chunks; we hand it the size when known so the - // request carries Content-Length, and fall back to chunked otherwise. - if ($size !== 0) { - if ($body->isSeekable()) { - $body->rewind(); - } - - $options[\CURLOPT_UPLOAD] = true; - $options[\CURLOPT_READFUNCTION] = static function (CurlHandle $handle, mixed $resource, int $length) use ($body): string { - unset($handle, $resource); - - return $body->eof() ? '' : $body->read($length); - }; - - if ($size !== null) { - $options[\CURLOPT_INFILESIZE] = $size; - } - } - - // '' advertises every codec cURL was built with and decodes the response. - if ($decompress) { - $options[\CURLOPT_ENCODING] = ''; - } - - $merged = $this->options + $options; - - // Authoritative over any caller-supplied CURLOPT_FORBID_REUSE / - // CURLOPT_FOLLOWLOCATION so constructor options cannot silently override - // the helpers. - $merged[\CURLOPT_FORBID_REUSE] = !$this->reuseConnections; - $merged[\CURLOPT_FOLLOWLOCATION] = $this->followRedirects; - - if ($this->followRedirects) { - $merged[\CURLOPT_MAXREDIRS] = Redirect::MAX_HOPS; - } - - return $merged; - } - - private function milliseconds(float $seconds): int - { - if ($seconds < 0.0 || !is_finite($seconds)) { - throw new ValueError('Timeout must be a finite number greater than or equal to zero.'); - } - - return (int) round($seconds * 1000); - } - - private function networkException(RequestInterface $request, string $message, int $code): NetworkException - { - if ($code === \CURLE_OPERATION_TIMEDOUT) { - return new TimeoutException($request, $message, $code); - } - - if (\in_array($code, $this->curlCodes([ - 'CURLE_COULDNT_RESOLVE_HOST', - 'CURLE_COULDNT_RESOLVE_PROXY', - ]), true)) { - return new DnsException($request, $message, $code); - } - - if (\in_array($code, $this->curlCodes([ - 'CURLE_PROXY', - 'CURLE_HTTP_PROXYTUNNEL', - ]), true)) { - return new ProxyException($request, $message, $code); - } - - if (\in_array($code, $this->curlCodes([ - 'CURLE_SSL_CONNECT_ERROR', - 'CURLE_PEER_FAILED_VERIFICATION', - 'CURLE_SSL_CACERT', - 'CURLE_SSL_PEER_CERTIFICATE', - 'CURLE_SSL_CACERT_BADFILE', - 'CURLE_SSL_CERTPROBLEM', - 'CURLE_SSL_CIPHER', - 'CURLE_SSL_ENGINE_NOTFOUND', - 'CURLE_SSL_ENGINE_SETFAILED', - 'CURLE_SSL_ENGINE_INITFAILED', - 'CURLE_USE_SSL_FAILED', - 'CURLE_SSL_SHUTDOWN_FAILED', - 'CURLE_SSL_CRL_BADFILE', - 'CURLE_SSL_ISSUER_ERROR', - 'CURLE_SSL_PINNEDPUBKEYNOTMATCH', - 'CURLE_SSL_INVALIDCERTSTATUS', - 'CURLE_SSL_CLIENTCERT', - 'CURLE_ECH_REQUIRED', - ]), true)) { - return new TlsException($request, $message, $code); - } - - if (\in_array($code, $this->curlCodes([ - 'CURLE_UNSUPPORTED_PROTOCOL', - 'CURLE_HTTP2', - 'CURLE_HTTP2_STREAM', - 'CURLE_HTTP3', - 'CURLE_QUIC_CONNECT_ERROR', - 'CURLE_WEIRD_SERVER_REPLY', - 'CURLE_BAD_CONTENT_ENCODING', - 'CURLE_CHUNK_FAILED', - 'CURLE_TOO_MANY_REDIRECTS', - 'CURLE_PARTIAL_FILE', - ]), true)) { - return new ProtocolException($request, $message, $code); - } - - if (\in_array($code, $this->curlCodes([ - 'CURLE_COULDNT_CONNECT', - 'CURLE_SEND_ERROR', - 'CURLE_RECV_ERROR', - 'CURLE_GOT_NOTHING', - 'CURLE_INTERFACE_FAILED', - 'CURLE_NO_CONNECTION_AVAILABLE', - 'CURLE_UNRECOVERABLE_POLL', - 'CURLE_AGAIN', - ]), true)) { - return new ConnectionException($request, $message, $code); - } - - return new NetworkException($request, $message, $code); - } - - /** - * @param array $names - * - * @return array - */ - private function curlCodes(array $names): array - { - $codes = []; - - foreach ($names as $name) { - if (\defined($name)) { - $code = \constant($name); - - if (\is_int($code)) { - $codes[] = $code; - } - } - } - - return $codes; - } - - /** - * @return array - */ - private function headers(RequestInterface $request): array - { - $headers = []; - - foreach ($request->getHeaders() as $name => $values) { - foreach ($values as $value) { - $headers[] = $name . ': ' . $value; - } - } - - if (!$request->hasHeader('Host') && $request->getUri()->getHost() !== '') { - $headers[] = 'Host: ' . $this->host($request); - } - - return $headers; - } - - private function host(RequestInterface $request): string - { - $uri = $request->getUri(); - $port = $uri->getPort(); - - if ($port === null) { - return $uri->getHost(); - } - - return $uri->getHost() . ':' . $port; - } - - /** - * cURL decodes the body but leaves the original Content-Encoding and - * Content-Length, which now misdescribe the plaintext body, so drop both - * when an encoding was present. - * - * @param array> $headers - * - * @return array> - */ - private function decoded(array $headers): array - { - $encoded = false; - - foreach (array_keys($headers) as $name) { - if (strcasecmp($name, Header::CONTENT_ENCODING) === 0) { - $encoded = true; - } - } - - if (!$encoded) { - return $headers; - } - - foreach (array_keys($headers) as $name) { - if (strcasecmp($name, Header::CONTENT_ENCODING) === 0 || strcasecmp($name, Header::CONTENT_LENGTH) === 0) { - unset($headers[$name]); - } - } - - return $headers; - } - - /** - * @return array{protocol: string, status: int, reason: string, headers: array>} - */ - private function parseHeaderBlock(string $headerBlock): array - { - $blocks = preg_split("/\r\n\r\n|\n\n|\r\r/", trim($headerBlock)); - $headers = $blocks === false ? [] : array_values(array_filter($blocks)); - $header = $headers === [] ? '' : array_last($headers); - $lines = preg_split("/\r\n|\n|\r/", $header); - $lines = $lines === false ? [] : $lines; - - $statusLine = array_shift($lines) ?? ''; - - $protocol = '1.1'; - $status = 0; - $reason = ''; - - if (preg_match('/^HTTP\/(?P\d+(?:\.\d+)?)\s+(?P\d{3})(?:\s+(?P.*))?$/i', $statusLine, $matches) === 1) { - $protocol = $matches['protocol']; - $status = (int) $matches['status']; - $reason = $matches['reason'] ?? ''; - } - - $parsedHeaders = []; - - foreach ($lines as $line) { - if (!str_contains($line, ':')) { - continue; - } - - [$name, $value] = explode(':', $line, 2); - $name = trim($name); - - if ($name === '') { - continue; - } - - $parsedHeaders[$name][] = trim($value); - } - - return [ - 'protocol' => $protocol, - 'status' => $status, - 'reason' => $reason, - 'headers' => $parsedHeaders, - ]; - } -} diff --git a/packages/client/src/Adapter/SwooleCoroutine/Client.php b/packages/client/src/Adapter/SwooleCoroutine/Client.php deleted file mode 100644 index 01b18616c..000000000 --- a/packages/client/src/Adapter/SwooleCoroutine/Client.php +++ /dev/null @@ -1,955 +0,0 @@ - $settings - */ - public function __construct( - ResponseFactoryInterface $responseFactory = new Response\Factory(), - StreamFactoryInterface $streamFactory = new Stream\Factory(), - private array $settings = [], - ) { - $this->settings += [ - self::SETTING_CONNECT_TIMEOUT => self::DEFAULT_CONNECT_TIMEOUT, - self::SETTING_TIMEOUT => self::DEFAULT_TIMEOUT, - self::SETTING_SSL_VERIFY_PEER => true, - ]; - - $this->responseBuilder = new ResponseBuilder($responseFactory, $streamFactory); - } - - public function __clone(): void - { - // Clones get their own connections; Swoole closes the dropped ones on GC. - $this->forgetConnection(); - } - - public function withTimeout(float $seconds): static - { - $clone = clone $this; - $clone->settings[self::SETTING_TIMEOUT] = $this->seconds($seconds); - - return $clone; - } - - public function withConnectTimeout(float $seconds): static - { - $clone = clone $this; - $clone->settings[self::SETTING_CONNECT_TIMEOUT] = $this->seconds($seconds); - - return $clone; - } - - public function withSslVerification(bool $enabled = true): static - { - $clone = clone $this; - $clone->settings[self::SETTING_SSL_VERIFY_PEER] = $enabled; - - return $clone; - } - - public function withCustomCA(string $path): static - { - $clone = clone $this; - $clone->settings[self::SETTING_SSL_CAFILE] = $path; - - return $clone; - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - $clone = clone $this; - $clone->settings[self::SETTING_SSL_CERT_FILE] = $certPath; - $clone->settings[self::SETTING_SSL_KEY_FILE] = $keyPath; - - if ($passphrase !== null) { - $clone->settings[self::SETTING_SSL_PASSPHRASE] = $passphrase; - } - - return $clone; - } - - public function withMinTlsVersion(Tls $version): static - { - $clone = clone $this; - $clone->settings[self::SETTING_SSL_PROTOCOLS] = $this->sslProtocols(match ($version) { - Tls::V1_0 => ['SWOOLE_SSL_TLSv1', 'SWOOLE_SSL_TLSv1_1', 'SWOOLE_SSL_TLSv1_2', 'SWOOLE_SSL_TLSv1_3'], - Tls::V1_1 => ['SWOOLE_SSL_TLSv1_1', 'SWOOLE_SSL_TLSv1_2', 'SWOOLE_SSL_TLSv1_3'], - Tls::V1_2 => ['SWOOLE_SSL_TLSv1_2', 'SWOOLE_SSL_TLSv1_3'], - Tls::V1_3 => ['SWOOLE_SSL_TLSv1_3'], - }); - - return $clone; - } - - public function withConnectionReuse(bool $enabled = true): static - { - $clone = clone $this; - $clone->reuseConnections = $enabled; - - return $clone; - } - - public function withFollowRedirects(bool $enabled = true): static - { - $clone = clone $this; - $clone->followRedirects = $enabled; - - return $clone; - } - - /** - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->perform($request, null); - } - - /** - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface - */ - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - return $this->perform($request, $sink); - } - - /** - * Execute the request, following Location redirects when enabled. The - * coroutine HTTP client has no native follow-redirects setting, so hops are - * issued here. When streaming, intermediate redirect bodies are discarded - * and only the final response body is forwarded to the sink. - * - * @param (callable(string): void)|null $sink - * - * @throws ClientExceptionInterface - */ - private function perform(RequestInterface $request, ?callable $sink): ResponseInterface - { - if (!\extension_loaded('swoole')) { - throw new AdapterPreconditionException($request, 'The swoole extension is required.'); - } - - if (Coroutine::getCid() < 0) { - throw new AdapterPreconditionException($request, 'Swoole coroutine HTTP requests must run inside a coroutine.'); - } - - $this->validateSettings(); - - try { - if (!$this->followRedirects) { - return $this->exchange($request, $sink); - } - - $current = $request; - - for ($redirects = 0; $redirects <= Redirect::MAX_HOPS; $redirects++) { - $response = $this->exchange($current, $sink, suppressRedirectBody: true); - - if (!Redirect::isRedirect($response)) { - return $response; - } - - if ($redirects === Redirect::MAX_HOPS) { - throw new ProtocolException($request, 'Too many redirects.'); - } - - $current = $this->followTo($current, $response); - } - - throw new ProtocolException($request, 'Too many redirects.'); - } finally { - if (!$this->reuseConnections) { - $this->forgetConnection(); - } elseif ($sink !== null) { - $this->releaseSink(); - } - } - } - - /** - * A write callback cannot be taken off a Swoole client, so replace the one - * holding $sink with a no-op. Whatever the sink captured is released, and - * the next stream on this connection installs its own callback anyway. - */ - private function releaseSink(): void - { - $this->streamConnection?->set(['write_func' => static function (): void {}]); - } - - /** - * Execute a single hop. When $sink is given, Swoole's write callback - * forwards each body chunk to it as data arrives, so the body is never - * fully held in memory and the returned response has an empty body; - * otherwise the body is buffered onto the response. - * - * @param (callable(string): void)|null $sink - * - * @throws ClientExceptionInterface - */ - private function exchange(RequestInterface $request, ?callable $sink, bool $suppressRedirectBody = false): ResponseInterface - { - $uri = $request->getUri(); - - if (!\in_array($uri->getScheme(), ['http', 'https'], true) || $uri->getHost() === '') { - throw new InvalidUriException($request, 'Requests must use an absolute URI.'); - } - - // Swoole decodes a buffered body itself, but its streaming write_func - // delivers it undecoded — so a stream must ask for identity instead. - $streaming = $sink !== null; - - $client = $this->connect($request, $streaming); - - $settings = $this->settings + [self::SETTING_HTTP2 => false]; - - // Authoritative over any keep_alive passed in $settings. - $settings[self::SETTING_KEEP_ALIVE] = $this->reuseConnections; - - if ($uri->getScheme() === 'https') { - // SNI alone does not verify hostnames; verify against this hop's URI host. - $settings[self::SETTING_SSL_HOST_NAME] = $uri->getHost(); - - // Reject IP literals: Swoole's X509_check_host can mistake numeric DNS SANs for IP identities. - if ($settings[self::SETTING_SSL_VERIFY_PEER] && filter_var(trim($uri->getHost(), '[]'), FILTER_VALIDATE_IP) !== false) { - throw new TlsException($request, 'Verified HTTPS requests to IP literals are not supported by the Swoole adapter; use a DNS hostname or the cURL adapter.'); - } - } - - $suppressedBody = null; - - if ($sink !== null) { - if ($suppressRedirectBody) { - $suppressedBody = tmpfile(); - - if ($suppressedBody === false) { - throw new InvalidArgumentException('Unable to buffer a redirect response body.'); - } - } - - $settings['write_func'] = function (SwooleClient $client, string $chunk) use ($sink, $suppressRedirectBody, $suppressedBody): void { - if ($suppressRedirectBody && $this->nativeResponseHasLocation($client)) { - if (\is_resource($suppressedBody)) { - fwrite($suppressedBody, $chunk); - } - - return; - } - - $sink($chunk); - }; - } else { - // $settings passes native settings through, and a caller-supplied - // write_func would leave every buffered body empty. - $settings['write_func'] = null; - } - - try { - if ($client->set($settings) === false) { - throw new InvalidArgumentException('Unable to configure Swoole client settings.'); - } - - if ($client->setMethod($request->getMethod()) === false) { - throw new InvalidArgumentException('Unable to configure Swoole request method.'); - } - - $body = $request->getBody(); - $multipart = $body instanceof Body && $this->streamableMultipart($body) ? $body : null; - - $headers = $this->requestHeaders($request); - - if ($streaming && !$request->hasHeader(Header::ACCEPT_ENCODING)) { - $headers[Header::ACCEPT_ENCODING] = 'identity'; - } - - // Swoole generates its own multipart Content-Type (with its own - // boundary) when files are attached, so drop ours to avoid a clash. - if ($multipart instanceof \Utopia\Psr7\Request\Multipart\Body) { - $headers = $this->withoutContentType($headers); - } - - if ($client->setHeaders($headers) === false) { - throw new InvalidArgumentException('Unable to configure Swoole request headers.'); - } - - // Swoole never clears requestBody and only clears uploadFiles after a - // successful response, so clear whichever this request omits. - if ($multipart instanceof \Utopia\Psr7\Request\Multipart\Body) { - $client->requestBody = null; - $this->attachMultipart($client, $multipart); - } else { - $client->uploadFiles = null; - $data = (string) $body; - - if ($data === '') { - $client->requestBody = null; - } elseif ($client->setData($data) === false) { - throw new InvalidArgumentException('Unable to configure Swoole request body.'); - } - } - } catch (InvalidArgumentException $invalidArgumentException) { - throw $invalidArgumentException; - } catch (Throwable $throwable) { - throw new InvalidArgumentException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); - } - - try { - $result = $client->execute($this->path($request)); - } catch (Throwable $throwable) { - throw $this->networkException($request, $throwable->getMessage(), (int) $throwable->getCode(), null, $throwable); - } - - if ($result === false) { - $message = \is_string($client->errMsg) && $client->errMsg !== '' ? $client->errMsg : 'Swoole request failed.'; - $code = \is_int($client->errCode) ? $client->errCode : 0; - $statusCode = $client->statusCode; - - if ($this->isTimeout($message, $code, $statusCode)) { - throw new TimeoutException($request, $message, $code); - } - - throw $this->networkException($request, $message, $code, $statusCode, null, $client->headers); - } - - $statusCode = $client->statusCode; - - if (!\is_int($statusCode) || $statusCode < 100 || $statusCode > 599) { - throw new InvalidResponseException($request, 'Received an invalid HTTP response.'); - } - - $headers = $client->headers; - - if (!\is_array($headers)) { - $headers = []; - } - - $headers = $this->headers($headers); - - if (!$streaming) { - $headers = $this->decoded($headers); - } - - $responseBody = $streaming ? '' : $client->body; - - if (!\is_string($responseBody)) { - $responseBody = ''; - } - - // Swoole keeps or closes the socket itself; never close it here. - $response = $this->responseBuilder->build( - $statusCode, - '', - $headers, - $responseBody, - ); - - if (\is_resource($suppressedBody) && $sink !== null && !Redirect::isRedirect($response)) { - rewind($suppressedBody); - - while (($chunk = fread($suppressedBody, 8192)) !== false && $chunk !== '') { - $sink($chunk); - } - } - - return $response; - } - - /** - * @throws ClientExceptionInterface - */ - private function followTo(RequestInterface $request, ResponseInterface $response): RequestInterface - { - $from = $request->getUri(); - - try { - $uri = Redirect::resolve($from, $response->getHeaderLine(Header::LOCATION)); - } catch (InvalidArgumentException $invalidArgumentException) { - throw new InvalidUriException($request, 'Invalid redirect location.', 0, $invalidArgumentException); - } - - if (!\in_array($uri->getScheme(), ['http', 'https'], true) || $uri->getHost() === '') { - throw new InvalidUriException($request, 'Redirect location must be an absolute HTTP URI.'); - } - - $status = $response->getStatusCode(); - $method = strtoupper($request->getMethod()); - - if (\in_array($status, [301, 302, 303], true) && !\in_array($method, ['GET', 'HEAD'], true)) { - $request = $request - ->withMethod(Method::GET) - ->withBody(new Stream\Factory()->createStream('')) - ->withoutHeader(Header::CONTENT_LENGTH) - ->withoutHeader(Header::CONTENT_TYPE); - } else { - $body = $request->getBody(); - - if ($body->isSeekable()) { - $body->rewind(); - } - } - - $request = $request->withUri($uri)->withoutHeader(Header::HOST); - - if (Redirect::shouldStripSensitiveHeaders($from, $uri)) { - return Redirect::withoutSensitiveHeaders($request); - } - - return $request; - } - - private function nativeResponseHasLocation(SwooleClient $client): bool - { - if (!\is_array($client->headers)) { - return false; - } - - return array_any($client->headers, fn($value, $name): bool => \is_string($name) && strcasecmp($name, Header::LOCATION) === 0 && \is_string($value) && $value !== ''); - } - - /** - * Swoole binds a client to its origin at construction and re-checks the - * socket before each request, reconnecting if it was dropped — so a cached - * client is reused per origin and stays usable even after an error. - * - * The object is cached for every hop of a single perform(), including - * when keep-alive is off, so a 50-hop follow does not leave 50 clients - * for later GC (which can abort PHP while another client reconnects). - * - * @throws ClientExceptionInterface - */ - private function connect(RequestInterface $request, bool $streaming): SwooleClient - { - $uri = $request->getUri(); - $secure = $uri->getScheme() === 'https'; - $key = $uri->getHost() . ':' . $this->port($request) . ':' . ($secure ? 's' : 'p'); - - $connection = $streaming ? $this->streamConnection : $this->connection; - $connectionKey = $streaming ? $this->streamConnectionKey : $this->connectionKey; - - if ($connection instanceof SwooleClient && $connectionKey === $key) { - $socket = $connection->socket ?? null; - if ($secure && $socket instanceof Coroutine\Socket) { - // SSL_peek can fail with errno=0 after an abrupt TLS EOF. Swoole's - // liveness check treats that as alive. Close before sending any body; - // a healthy idle socket instead reports a would-block error. - $peek = $socket->peek(1); - if ($peek === '' || ($peek === false && $socket->errCode === 0)) { - $connection->close(); - } - } - - return $connection; - } - - try { - $client = new SwooleClient($uri->getHost(), $this->port($request), $secure); - } catch (Throwable $throwable) { - throw new AdapterInitializationException($request, $throwable->getMessage(), (int) $throwable->getCode(), $throwable); - } - - if ($streaming) { - $this->streamConnection = $client; - $this->streamConnectionKey = $key; - } else { - $this->connection = $client; - $this->connectionKey = $key; - } - - return $client; - } - - private function forgetConnection(): void - { - $this->connection = null; - $this->connectionKey = ''; - $this->streamConnection = null; - $this->streamConnectionKey = ''; - } - - private function port(RequestInterface $request): int - { - $uri = $request->getUri(); - $port = $uri->getPort(); - - if ($port !== null) { - return $port; - } - - return $uri->getScheme() === 'https' ? 443 : 80; - } - - private function seconds(float $seconds): float - { - if ($seconds < 0.0 || !is_finite($seconds)) { - throw new ValueError('Timeout must be a finite number greater than or equal to zero.'); - } - - return $seconds; - } - - private function validateSettings(): void - { - foreach ([self::SETTING_TIMEOUT, self::SETTING_CONNECT_TIMEOUT] as $setting) { - if (!\array_key_exists($setting, $this->settings)) { - continue; - } - - $value = $this->settings[$setting]; - - if (!\is_int($value) && !\is_float($value)) { - throw new InvalidArgumentException('Swoole setting "' . $setting . '" must be a finite number greater than or equal to zero.'); - } - - if ($value < 0 || !is_finite((float) $value)) { - throw new InvalidArgumentException('Swoole setting "' . $setting . '" must be a finite number greater than or equal to zero.'); - } - } - - if (\array_key_exists(self::SETTING_HTTP2, $this->settings) && !\is_bool($this->settings[self::SETTING_HTTP2])) { - throw new InvalidArgumentException('Swoole setting "' . self::SETTING_HTTP2 . '" must be a boolean.'); - } - } - - private function path(RequestInterface $request): string - { - $uri = $request->getUri(); - $path = $uri->getPath() === '' ? '/' : $uri->getPath(); - $query = $uri->getQuery(); - - if ($query === '') { - return $path; - } - - return $path . '?' . $query; - } - - private function isTimeout(string $message, int $code, mixed $statusCode): bool - { - unset($message); - - if ($this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_REQUEST_TIMEOUT', -2)) { - return true; - } - - return \in_array($code, $this->nativeCodes([ - 'SOCKET_ETIMEDOUT', - 'SWOOLE_ERROR_SOCKET_POLL_TIMEOUT', - ], [110]), true); - } - - private function networkException(RequestInterface $request, string $message, int $code, mixed $statusCode = null, ?Throwable $previous = null, mixed $headers = null): NetworkException - { - if ($this->isTimeout($message, $code, $statusCode)) { - return new TimeoutException($request, $message, $code, $previous); - } - - if (\in_array($code, $this->nativeCodes([ - 'SWOOLE_ERROR_DNSLOOKUP_RESOLVE_FAILED', - 'SWOOLE_ERROR_DNSLOOKUP_RESOLVE_TIMEOUT', - 'SWOOLE_ERROR_DNSLOOKUP_NO_SERVER', - ]), true)) { - return new DnsException($request, $message, $code, $previous); - } - - if (\in_array($code, $this->nativeCodes([ - 'SWOOLE_ERROR_SSL_NOT_READY', - 'SWOOLE_ERROR_SSL_EMPTY_PEER_CERTIFICATE', - 'SWOOLE_ERROR_SSL_VERIFY_FAILED', - 'SWOOLE_ERROR_SSL_BAD_CLIENT', - 'SWOOLE_ERROR_SSL_BAD_PROTOCOL', - 'SWOOLE_ERROR_SSL_RESET', - 'SWOOLE_ERROR_SSL_HANDSHAKE_FAILED', - 'SWOOLE_ERROR_SSL_CREATE_CONTEXT_FAILED', - 'SWOOLE_ERROR_SSL_CREATE_SESSION_FAILED', - ]), true)) { - return new TlsException($request, $message, $code, $previous); - } - - if (\in_array($code, $this->nativeCodes([ - 'SWOOLE_ERROR_HTTP_PROXY_HANDSHAKE_ERROR', - 'SWOOLE_ERROR_HTTP_PROXY_HANDSHAKE_FAILED', - 'SWOOLE_ERROR_HTTP_PROXY_BAD_RESPONSE', - 'SWOOLE_ERROR_SOCKS5_UNSUPPORT_VERSION', - 'SWOOLE_ERROR_SOCKS5_UNSUPPORT_METHOD', - 'SWOOLE_ERROR_SOCKS5_AUTH_FAILED', - 'SWOOLE_ERROR_SOCKS5_SERVER_ERROR', - 'SWOOLE_ERROR_SOCKS5_HANDSHAKE_FAILED', - ]), true)) { - return new ProxyException($request, $message, $code, $previous); - } - - if (\in_array($code, $this->nativeCodes([ - 'SWOOLE_ERROR_PROTOCOL_ERROR', - 'SWOOLE_ERROR_HTTP_INVALID_PROTOCOL', - 'SWOOLE_ERROR_PACKAGE_MALFORMED_DATA', - 'SWOOLE_ERROR_HTTP2_STREAM_NO_HEADER', - 'SWOOLE_ERROR_HTTP2_SEND_CONTROL_FRAME_FAILED', - 'SWOOLE_ERROR_HTTP2_INTERNAL_ERROR', - ]), true)) { - return new ProtocolException($request, $message, $code, $previous); - } - - if (\is_array($headers) && \in_array($code, $this->nativeCodes([ - 'SOCKET_ECONNRESET', - ], [104]), true)) { - return new ProtocolException($request, $message, $code, $previous); - } - - if (\in_array($code, $this->nativeCodes([ - 'SOCKET_EPIPE', - 'SOCKET_ENETUNREACH', - 'SOCKET_ECONNRESET', - 'SOCKET_ECONNREFUSED', - 'SOCKET_EHOSTUNREACH', - 'SWOOLE_ERROR_CLIENT_NO_CONNECTION', - 'SWOOLE_ERROR_SESSION_CLOSED_BY_SERVER', - 'SWOOLE_ERROR_SESSION_CLOSED_BY_CLIENT', - 'SWOOLE_ERROR_SESSION_CLOSED', - ], [32, 101, 104, 111, 113]), true)) { - return new ConnectionException($request, $message, $code, $previous); - } - - if ($this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_CONNECT_FAILED', -1) || $this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_SERVER_RESET', -3) || $this->statusCodeIs($statusCode, 'SWOOLE_HTTP_CLIENT_ESTATUS_SEND_FAILED', -4)) { - return new ConnectionException($request, $message, $code, $previous); - } - - return new NetworkException($request, $message, $code, $previous); - } - - /** - * OR the given SWOOLE_SSL_* protocol constants into the bitmask Swoole's - * ssl_protocols setting expects, skipping any the runtime does not define. - * - * @param array $names - */ - private function sslProtocols(array $names): int - { - $mask = 0; - - foreach ($names as $name) { - if (!\defined($name)) { - continue; - } - - $value = \constant($name); - - if (\is_int($value)) { - $mask |= $value; - } - } - - return $mask; - } - - /** - * @param array $names - * @param array $fallbacks - * - * @return array - */ - private function nativeCodes(array $names, array $fallbacks = []): array - { - $codes = $fallbacks; - - foreach ($names as $name) { - if (!\defined($name)) { - continue; - } - - $code = \constant($name); - - if (\is_int($code)) { - $codes[] = $code; - } - } - - return array_values(array_unique($codes)); - } - - private function statusCodeIs(mixed $statusCode, string $constant, int $fallback): bool - { - if (!\is_int($statusCode)) { - return false; - } - - if (\defined($constant) && \constant($constant) === $statusCode) { - return true; - } - - return $statusCode === $fallback; - } - - /** - * Whether a multipart body can be sent through Swoole's native upload API, - * which streams files from disk with sendfile(). It only models simple - * uploads: at least one file or data part, no custom per-part headers, no - * repeated field names, and no empty files (Swoole rejects those). Anything - * else falls back to buffering the serialised body. - */ - private function streamableMultipart(Body $body): bool - { - $hasUpload = false; - $fields = []; - - foreach ($body->parts() as $part) { - if ($part->headers() !== []) { - return false; - } - - if ($part->path() !== null) { - if ($part->size() === 0) { - return false; - } - - $hasUpload = true; - - continue; - } - - if ($part->filename() !== null) { - $hasUpload = true; - - continue; - } - - if (isset($fields[$part->name()])) { - return false; - } - - $fields[$part->name()] = true; - } - - return $hasUpload; - } - - private function attachMultipart(SwooleClient $client, Body $body): void - { - $fields = []; - - foreach ($body->parts() as $part) { - if ($part->path() !== null) { - if ($client->addFile($part->path(), $part->name(), $part->contentType() ?? '', $part->filename() ?? '') === false) { - throw new InvalidArgumentException('Unable to attach Swoole multipart file.'); - } - - continue; - } - - if ($part->filename() !== null) { - if ($client->addData($part->content(), $part->name(), $part->contentType() ?? '', $part->filename()) === false) { - throw new InvalidArgumentException('Unable to attach Swoole multipart data.'); - } - - continue; - } - - $fields[$part->name()] = $part->content(); - } - - if ($fields !== [] && $client->setData($fields) === false) { - throw new InvalidArgumentException('Unable to configure Swoole multipart fields.'); - } - } - - /** - * @param array $headers - * - * @return array - */ - private function withoutContentType(array $headers): array - { - foreach (array_keys($headers) as $name) { - if (strtolower((string) $name) === 'content-type') { - unset($headers[$name]); - } - } - - return $headers; - } - - /** - * @return array - */ - private function requestHeaders(RequestInterface $request): array - { - $headers = []; - - foreach ($request->getHeaders() as $name => $values) { - $headers[$name] = implode(', ', $values); - } - - if (!$request->hasHeader('Host') && $request->getUri()->getHost() !== '') { - $headers['Host'] = $this->host($request); - } - - return $headers; - } - - private function host(RequestInterface $request): string - { - $uri = $request->getUri(); - $port = $uri->getPort(); - - if ($port === null) { - return $uri->getHost(); - } - - return $uri->getHost() . ':' . $port; - } - - /** - * Swoole decodes a buffered gzip/deflate/br body but leaves the original - * Content-Encoding and Content-Length, which now misdescribe the plaintext - * body, so drop both for an encoding Swoole decodes. - * - * @param array> $headers - * - * @return array> - */ - private function decoded(array $headers): array - { - $decoded = false; - - foreach ($headers as $name => $values) { - if (strcasecmp($name, Header::CONTENT_ENCODING) !== 0) { - continue; - } - - foreach ($values as $value) { - if (\in_array(strtolower(trim($value)), ['gzip', 'deflate', 'br'], true)) { - $decoded = true; - } - } - } - - if (!$decoded) { - return $headers; - } - - foreach (array_keys($headers) as $name) { - if (strcasecmp($name, Header::CONTENT_ENCODING) === 0 || strcasecmp($name, Header::CONTENT_LENGTH) === 0) { - unset($headers[$name]); - } - } - - return $headers; - } - - /** - * @param array $headers - * - * @return array> - */ - private function headers(array $headers): array - { - $normalized = []; - - foreach ($headers as $name => $value) { - if (!\is_string($name)) { - continue; - } - - if (\is_string($value)) { - $normalized[$name] = [$value]; - - continue; - } - - if (!\is_array($value)) { - continue; - } - - foreach ($value as $singleValue) { - if (\is_string($singleValue)) { - $normalized[$name][] = $singleValue; - } - } - } - - return $normalized; - } -} diff --git a/packages/client/src/Client.php b/packages/client/src/Client.php deleted file mode 100644 index 453a5d8a9..000000000 --- a/packages/client/src/Client.php +++ /dev/null @@ -1,249 +0,0 @@ -}> - */ - private array $headers = []; - - private ?UriInterface $baseUri = null; - - private bool $tracePropagation = false; - - public function __construct( - private Adapter $adapter, - ) {} - - public function withTimeout(float $seconds): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withTimeout($seconds); - - return $clone; - } - - public function withConnectTimeout(float $seconds): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withConnectTimeout($seconds); - - return $clone; - } - - public function withSslVerification(bool $enabled = true): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withSslVerification($enabled); - - return $clone; - } - - public function withCustomCA(string $path): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withCustomCA($path); - - return $clone; - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withCertificate($certPath, $keyPath, $passphrase); - - return $clone; - } - - public function withMinTlsVersion(Tls $version): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withMinTlsVersion($version); - - return $clone; - } - - public function withConnectionReuse(bool $enabled = true): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withConnectionReuse($enabled); - - return $clone; - } - - public function withFollowRedirects(bool $enabled = true): static - { - $clone = clone $this; - $clone->adapter = $this->adapter->withFollowRedirects($enabled); - - return $clone; - } - - /** - * @param array> $headers - */ - public function withHeaders(array $headers): static - { - $clone = clone $this; - - foreach ($headers as $name => $values) { - $clone->headers[strtolower($name)] = [ - 'name' => $name, - 'values' => \is_array($values) ? array_values($values) : [$values], - ]; - } - - return $clone; - } - - public function withBaseUri(UriInterface|string $uri): static - { - $uri = $uri instanceof UriInterface ? $uri : Uri::parse($uri); - - if ($uri->getScheme() === '' || $uri->getHost() === '') { - throw new InvalidArgumentException('Base URI must be absolute.'); - } - - $clone = clone $this; - $clone->baseUri = $uri; - - return $clone; - } - - public function withBasicAuth(string $username, string $password): static - { - return $this->withHeaders([ - Header::AUTHORIZATION => 'Basic ' . base64_encode($username . ':' . $password), - ]); - } - - public function withBearerAuth(string $token): static - { - return $this->withHeaders([ - Header::AUTHORIZATION => 'Bearer ' . $token, - ]); - } - - /** - * Propagate the active trace downstream as a W3C Trace Context traceparent - * header. Off by default; requires an active utopia-php/span span. - */ - public function withTracePropagation(bool $enabled = true): static - { - $clone = clone $this; - $clone->tracePropagation = $enabled; - - return $clone; - } - - /** - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->adapter->sendRequest($this->prepare($request)); - } - - /** - * Send a request and pass each response body chunk to $sink as it arrives. - * The returned response carries the status and headers; its body is empty. - * - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface - */ - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - return $this->adapter->stream($this->prepare($request), $sink); - } - - private function prepare(RequestInterface $request): RequestInterface - { - return $this->applyTrace( - $this->applyHeaders( - $this->applyBaseUri($request), - ), - ); - } - - private function applyBaseUri(RequestInterface $request): RequestInterface - { - if (!$this->baseUri instanceof \Psr\Http\Message\UriInterface) { - return $request; - } - - $uri = $request->getUri(); - - if ($uri->getScheme() !== '' || $uri->getHost() !== '') { - return $request; - } - - return $request->withUri( - $this->baseUri - ->withPath($this->resolvePath($this->baseUri->getPath(), $uri->getPath())) - ->withQuery($uri->getQuery()) - ->withFragment($uri->getFragment()), - ); - } - - private function applyHeaders(RequestInterface $request): RequestInterface - { - foreach ($this->headers as $header) { - if (!$request->hasHeader($header['name'])) { - $request = $request->withHeader($header['name'], $header['values']); - } - } - - return $request; - } - - /** - * Propagate the active trace downstream as a W3C Trace Context traceparent - * header. Skipped when disabled, no span is active, or the request already - * carries one, so an inbound trace is never overwritten. - */ - private function applyTrace(RequestInterface $request): RequestInterface - { - if (!$this->tracePropagation) { - return $request; - } - - $traceparent = Span::traceparent(); - - if ($traceparent === null || $request->hasHeader(Header::TRACEPARENT)) { - return $request; - } - - return $request->withHeader(Header::TRACEPARENT, $traceparent); - } - - private function resolvePath(string $basePath, string $path): string - { - if ($path === '') { - return $basePath === '' ? '/' : $basePath; - } - - if (str_starts_with($path, '/')) { - return Redirect::removeDotSegments($path); - } - - if ($basePath === '' || !str_ends_with($basePath, '/')) { - $basePath .= '/'; - } - - return Redirect::removeDotSegments($basePath . $path); - } -} diff --git a/packages/client/src/Decorator.php b/packages/client/src/Decorator.php deleted file mode 100644 index c56aab860..000000000 --- a/packages/client/src/Decorator.php +++ /dev/null @@ -1,88 +0,0 @@ -wrap($this->adapter->withTimeout($seconds)); - } - - public function withConnectTimeout(float $seconds): static - { - return $this->wrap($this->adapter->withConnectTimeout($seconds)); - } - - public function withSslVerification(bool $enabled = true): static - { - return $this->wrap($this->adapter->withSslVerification($enabled)); - } - - public function withCustomCA(string $path): static - { - return $this->wrap($this->adapter->withCustomCA($path)); - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - return $this->wrap($this->adapter->withCertificate($certPath, $keyPath, $passphrase)); - } - - public function withMinTlsVersion(Tls $version): static - { - return $this->wrap($this->adapter->withMinTlsVersion($version)); - } - - public function withConnectionReuse(bool $enabled = true): static - { - return $this->wrap($this->adapter->withConnectionReuse($enabled)); - } - - public function withFollowRedirects(bool $enabled = true): static - { - return $this->wrap($this->adapter->withFollowRedirects($enabled)); - } - - /** - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->adapter->sendRequest($request); - } - - /** - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface - */ - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - return $this->adapter->stream($request, $sink); - } - - protected function wrap(Adapter $adapter): static - { - $clone = clone $this; - $clone->adapter = $adapter; - - return $clone; - } -} diff --git a/packages/client/src/Decorator/Retry.php b/packages/client/src/Decorator/Retry.php deleted file mode 100644 index b935dc8d2..000000000 --- a/packages/client/src/Decorator/Retry.php +++ /dev/null @@ -1,102 +0,0 @@ -sleep = $sleep ?? static function (float $seconds): void { - usleep((int) round($seconds * 1_000_000)); - }; - } - - #[\Override] - public function sendRequest(RequestInterface $request): ResponseInterface - { - for ($attempt = 1; ; $attempt++) { - try { - $response = $this->adapter->sendRequest($request); - $delay = $this->strategy->delay($request, $attempt, $response, null); - - if ($delay === null) { - return $response; - } - } catch (ClientExceptionInterface $error) { - $delay = $this->strategy->delay($request, $attempt, null, $error); - - if ($delay === null) { - throw $error; - } - } - - ($this->sleep)($delay); - } - } - - #[\Override] - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - for ($attempt = 1; ; $attempt++) { - $delivered = 0; - $countingSink = static function (string $chunk) use ($sink, &$delivered): void { - $delivered += \strlen($chunk); - $sink($chunk); - }; - - try { - $response = $this->adapter->stream($request, $countingSink); - - // Once bytes have reached the sink, replaying would duplicate them. - if ($delivered > 0) { - return $response; - } - - $delay = $this->strategy->delay($request, $attempt, $response, null); - - if ($delay === null) { - return $response; - } - } catch (ClientExceptionInterface $error) { - if ($delivered > 0) { - throw $error; - } - - $delay = $this->strategy->delay($request, $attempt, null, $error); - - if ($delay === null) { - throw $error; - } - } - - ($this->sleep)($delay); - } - } -} diff --git a/packages/client/src/Decorator/Retry/Backoff.php b/packages/client/src/Decorator/Retry/Backoff.php deleted file mode 100644 index 1e9edc0ed..000000000 --- a/packages/client/src/Decorator/Retry/Backoff.php +++ /dev/null @@ -1,102 +0,0 @@ - - */ - private const array IDEMPOTENT_METHODS = [ - Method::GET, - Method::HEAD, - Method::PUT, - Method::DELETE, - Method::OPTIONS, - Method::TRACE, - ]; - - /** - * @var array - */ - private const array RETRYABLE_STATUS_CODES = [429, 502, 503, 504]; - - private Closure $randomizer; - - /** - * @param (Closure(): float)|null $randomizer returns a value in [0, 1) for jitter - */ - public function __construct( - private int $maxAttempts = 3, - private float $baseDelay = 0.1, - private float $maxDelay = 10.0, - private float $multiplier = 2.0, - ?Closure $randomizer = null, - ) { - $this->randomizer = $randomizer ?? static fn(): float => mt_rand() / mt_getrandmax(); - } - - public function delay(RequestInterface $request, int $attempt, ?ResponseInterface $response, ?ClientExceptionInterface $error): ?float - { - if ($attempt >= $this->maxAttempts) { - return null; - } - - if (!\in_array($request->getMethod(), self::IDEMPOTENT_METHODS, true)) { - return null; - } - - if (!$this->isRetryable($response, $error)) { - return null; - } - - return $this->retryAfter($response) ?? $this->backoff($attempt); - } - - private function isRetryable(?ResponseInterface $response, ?ClientExceptionInterface $error): bool - { - if ($error instanceof \Psr\Http\Client\ClientExceptionInterface) { - return $error instanceof NetworkExceptionInterface; - } - - return $response instanceof \Psr\Http\Message\ResponseInterface && \in_array($response->getStatusCode(), self::RETRYABLE_STATUS_CODES, true); - } - - private function retryAfter(?ResponseInterface $response): ?float - { - if (!$response instanceof \Psr\Http\Message\ResponseInterface) { - return null; - } - - $value = $response->getHeaderLine(Header::RETRY_AFTER); - - if (!is_numeric($value)) { - return null; - } - - return min($this->maxDelay, max(0.0, (float) $value)); - } - - private function backoff(int $attempt): float - { - $ceiling = min($this->maxDelay, $this->baseDelay * $this->multiplier ** ($attempt - 1)); - - return ($this->randomizer)() * $ceiling; - } -} diff --git a/packages/client/src/Decorator/Retry/Strategy.php b/packages/client/src/Decorator/Retry/Strategy.php deleted file mode 100644 index 3165f2c27..000000000 --- a/packages/client/src/Decorator/Retry/Strategy.php +++ /dev/null @@ -1,21 +0,0 @@ -request; - } -} diff --git a/packages/client/src/Exception/ProtocolException.php b/packages/client/src/Exception/ProtocolException.php deleted file mode 100644 index 647ee59cc..000000000 --- a/packages/client/src/Exception/ProtocolException.php +++ /dev/null @@ -1,7 +0,0 @@ -request; - } -} diff --git a/packages/client/src/Exception/TimeoutException.php b/packages/client/src/Exception/TimeoutException.php deleted file mode 100644 index 3f6999eda..000000000 --- a/packages/client/src/Exception/TimeoutException.php +++ /dev/null @@ -1,7 +0,0 @@ - $connections - */ - public function __construct( - private Connections $connections, - ) {} - - /** - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->connections->use( - fn(ClientInterface $client): ResponseInterface => $client->sendRequest($request), - ); - } - - /** - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface - */ - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - return $this->connections->use( - fn(StreamingClientInterface $client): ResponseInterface => $client->stream($request, $sink), - ); - } -} diff --git a/packages/client/src/Psr18/StreamingClientInterface.php b/packages/client/src/Psr18/StreamingClientInterface.php deleted file mode 100644 index 6119648ce..000000000 --- a/packages/client/src/Psr18/StreamingClientInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ - private const array SENSITIVE_HEADERS = [ - Header::AUTHORIZATION, - Header::COOKIE, - 'Cookie2', - 'Proxy-Authorization', - ]; - - private function __construct() {} - - public static function isRedirect(ResponseInterface $response): bool - { - $status = $response->getStatusCode(); - - return $status >= 300 && $status < 400 && $response->getHeaderLine(Header::LOCATION) !== ''; - } - - public static function resolve(UriInterface $base, string $location): UriInterface - { - $target = Uri::parse($location); - - if ($target->getScheme() !== '') { - return $target->withPath(self::removeDotSegments($target->getPath())); - } - - if ($target->getHost() !== '') { - return $target - ->withScheme($base->getScheme()) - ->withPath(self::removeDotSegments($target->getPath())); - } - - $path = $target->getPath(); - - if ($path === '') { - $query = self::locationHasQuery($location) ? $target->getQuery() : $base->getQuery(); - - return $base - ->withQuery($query) - ->withFragment($target->getFragment()); - } - - if (str_starts_with($path, '/')) { - return $base - ->withPath(self::removeDotSegments($path)) - ->withQuery($target->getQuery()) - ->withFragment($target->getFragment()); - } - - return $base - ->withPath(self::removeDotSegments(self::merge($base, $path))) - ->withQuery($target->getQuery()) - ->withFragment($target->getFragment()); - } - - /** - * RFC 3986 section 5.2.4 remove_dot_segments algorithm. - */ - public static function removeDotSegments(string $path): string - { - $output = ''; - - while ($path !== '') { - if (str_starts_with($path, '../')) { - $path = substr($path, 3); - } elseif (str_starts_with($path, './')) { - $path = substr($path, 2); - } elseif (str_starts_with($path, '/./')) { - $path = substr($path, 2); - } elseif ($path === '/.') { - $path = '/'; - } elseif (str_starts_with($path, '/../')) { - $path = substr($path, 3); - $output = self::withoutLastPathSegment($output); - } elseif ($path === '/..') { - $path = '/'; - $output = self::withoutLastPathSegment($output); - } elseif ($path === '.' || $path === '..') { - $path = ''; - } else { - $nextSlash = strpos($path, '/', str_starts_with($path, '/') ? 1 : 0); - - if ($nextSlash === false) { - $output .= $path; - $path = ''; - } else { - $output .= substr($path, 0, $nextSlash); - $path = substr($path, $nextSlash); - } - } - } - - return $output; - } - - public static function isSameOrigin(UriInterface $from, UriInterface $to): bool - { - return self::origin($from) === self::origin($to); - } - - /** - * Strip credentials when the origin changes. Origin includes the scheme, so - * an HTTPS to HTTP hop on the same host is stripped too. - */ - public static function shouldStripSensitiveHeaders(UriInterface $from, UriInterface $to): bool - { - return !self::isSameOrigin($from, $to); - } - - public static function withoutSensitiveHeaders(RequestInterface $request): RequestInterface - { - foreach (self::SENSITIVE_HEADERS as $header) { - $request = $request->withoutHeader($header); - } - - return $request; - } - - private static function locationHasQuery(string $location): bool - { - return str_contains(explode('#', $location, 2)[0], '?'); - } - - private static function withoutLastPathSegment(string $path): string - { - $slash = strrpos($path, '/'); - - return $slash === false ? '' : substr($path, 0, $slash); - } - - /** - * RFC 3986 5.2.3: merge a relative path against the base URI's path. - */ - private static function merge(UriInterface $base, string $relative): string - { - $basePath = $base->getPath(); - - if ($base->getAuthority() !== '' && $basePath === '') { - return '/' . $relative; - } - - $slash = strrpos($basePath, '/'); - - if ($slash === false) { - return $relative; - } - - return substr($basePath, 0, $slash + 1) . $relative; - } - - private static function origin(UriInterface $uri): string - { - $scheme = strtolower($uri->getScheme()); - $port = $uri->getPort(); - - if ($port === null) { - $port = $scheme === 'https' ? 443 : 80; - } - - return $scheme . '://' . strtolower($uri->getHost()) . ':' . $port; - } -} diff --git a/packages/client/src/Response/Builder.php b/packages/client/src/Response/Builder.php deleted file mode 100644 index 8bc716f06..000000000 --- a/packages/client/src/Response/Builder.php +++ /dev/null @@ -1,41 +0,0 @@ -> $headers - */ - public function build( - int $statusCode, - string $reasonPhrase, - array $headers, - string $body, - string $protocolVersion = '1.1', - ): ResponseInterface { - $response = $this->responseFactory - ->createResponse($statusCode, $reasonPhrase) - ->withProtocolVersion($protocolVersion) - ->withBody($this->streamFactory->createStream($body)); - - foreach ($headers as $name => $values) { - foreach ($values as $value) { - $response = $response->withAddedHeader($name, $value); - } - } - - return $response; - } -} diff --git a/packages/client/src/Tls.php b/packages/client/src/Tls.php deleted file mode 100644 index 9006f5a7f..000000000 --- a/packages/client/src/Tls.php +++ /dev/null @@ -1,20 +0,0 @@ - $transportOptions - */ - abstract protected function createAdapter(array $transportOptions = []): Adapter; - - abstract protected function runAdapter(callable $callback): void; - - abstract protected function requireAdapterAvailable(): void; - - /** - * @return array - */ - abstract protected function invalidTransportOptions(): array; - - /** - * @return array - */ - abstract protected function timeoutOptions(float $timeout, ?float $connectTimeout = null): array; - - /** - * @return array - */ - abstract protected function proxyOptions(int $port): array; - - /** - * Native constructor options that would enable or disable following - * redirects if the helper were not authoritative over them. - * - * @return array - */ - abstract protected function followRedirectsTransportOptions(bool $enabled): array; - - protected function setUp(): void - { - $this->requireAdapterAvailable(); - } - - public function testItRequiresAbsoluteUris(): void - { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $this->expectException(InvalidUriException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, '/relative')); - } - - public function testItRejectsUnsupportedUriSchemes(): void - { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $this->expectException(InvalidUriException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, 'ftp://example.com/resource')); - } - - public function testItSendsRequests(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $streamFactory = new Stream\Factory(); - $client = $this->createAdapter(); - $request = $requestFactory->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/echo') - ->withHeader(Header::CONTENT_TYPE, ContentType::PLAIN_TEXT) - ->withHeader('X-Custom', 'sent') - ->withBody($streamFactory->createStream('hello')); - - $response = $this->send($client, $request); - - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('text/plain;charset=UTF-8', $response->getHeaderLine(Header::CONTENT_TYPE)); - $this->assertSame('POST:/echo:sent:hello', (string) $response->getBody()); - }); - } - - public function testItReturnsClientAndServerErrorResponsesWithoutThrowing(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $notFound = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/not-found')); - $serverError = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/server-error')); - - $this->assertSame(404, $notFound->getStatusCode()); - $this->assertSame('missing', (string) $notFound->getBody()); - $this->assertSame(500, $serverError->getStatusCode()); - $this->assertSame('failed', (string) $serverError->getBody()); - }); - } - - public function testItDoesNotFollowRedirectsByDefault(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $response = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect')); - - $this->assertSame(302, $response->getStatusCode()); - $this->assertSame('/final', $response->getHeaderLine(Header::LOCATION)); - $this->assertSame('redirect', (string) $response->getBody()); - }); - } - - public function testItDoesNotFollowRedirectsWhenStreaming(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect'); - $client = $this->createAdapter(); - - $received = ''; - - $response = $this->sendStream($client, $request, function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - $this->assertSame(302, $response->getStatusCode()); - $this->assertSame('/final', $response->getHeaderLine(Header::LOCATION)); - $this->assertSame('redirect', $received); - $this->assertSame('', (string) $response->getBody()); - }); - } - - public function testItFollowsRedirectsWhenEnabled(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter()->withFollowRedirects(); - - $response = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect')); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('final', (string) $response->getBody()); - $this->assertSame('', $response->getHeaderLine(Header::LOCATION)); - }); - } - - public function testItFollowsRedirectsWhenStreaming(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect'); - $client = $this->createAdapter()->withFollowRedirects(); - - $received = ''; - - $response = $this->sendStream($client, $request, function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('final', $received); - $this->assertSame('', (string) $response->getBody()); - $this->assertSame('', $response->getHeaderLine(Header::LOCATION)); - }); - } - - public function testConstructorFollowRedirectsOptionsDoNotOverrideTheDefault(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter($this->followRedirectsTransportOptions(true)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect'); - - $response = $this->send($client, $request); - - $this->assertSame(302, $response->getStatusCode()); - $this->assertSame('/final', $response->getHeaderLine(Header::LOCATION)); - $this->assertSame('redirect', (string) $response->getBody()); - }); - } - - public function testWithFollowRedirectsOverridesConstructorTransportOptions(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter($this->followRedirectsTransportOptions(false))->withFollowRedirects(); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect'); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('final', (string) $response->getBody()); - }); - } - - public function testItFollowsPathRelativeRedirects(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter()->withFollowRedirects(); - $origin = 'http://127.0.0.1:' . $port; - - $parent = $this->send($client, $requestFactory->createRequest(Method::GET, $origin . '/nested/parent')); - $dot = $this->send($client, $requestFactory->createRequest(Method::GET, $origin . '/nested/dot')); - $plain = $this->send($client, $requestFactory->createRequest(Method::GET, $origin . '/nested/plain')); - - $this->assertSame(200, $parent->getStatusCode()); - $this->assertSame('final', (string) $parent->getBody()); - $this->assertSame(200, $dot->getStatusCode()); - $this->assertSame('nested-final', (string) $dot->getBody()); - $this->assertSame(200, $plain->getStatusCode()); - $this->assertSame('nested-final', (string) $plain->getBody()); - }); - } - - public function testItFollowsAbsoluteRedirectUris(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter()->withFollowRedirects(); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect-absolute'); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('final', (string) $response->getBody()); - }); - } - - public function testItAllowsFiftyRedirectHops(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter()->withFollowRedirects(); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/hops/50'); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('hopped', (string) $response->getBody()); - }); - } - - public function testItRejectsFiftyOneRedirectHops(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter()->withFollowRedirects(); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/hops/51'); - - $this->expectException(ProtocolException::class); - - $this->send($client, $request); - }); - } - - public function testItKeepsAuthorizationOnSameOriginRedirects(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory() - ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect-auth') - ->withHeader(Header::AUTHORIZATION, 'Bearer secret') - ->withHeader(Header::COOKIE, 'session=keep'); - $client = $this->createAdapter()->withFollowRedirects(); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('Bearer secret|session=keep', (string) $response->getBody()); - }); - } - - public function testItStripsAuthorizationOnCrossOriginRedirects(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory() - ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect-cross') - ->withHeader(Header::AUTHORIZATION, 'Bearer secret') - ->withHeader(Header::COOKIE, 'session=drop'); - $client = $this->createAdapter()->withFollowRedirects(); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('|', (string) $response->getBody()); - }); - } - - public function testItStreamsTheFinalHopAfterARedirect(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/redirect-large'); - $client = $this->createAdapter()->withFollowRedirects(); - - $expected = self::PAYLOAD_SIZE; - $hash = hash_init('sha256'); - $read = 0; - $baseline = memory_get_usage(); - $peak = 0; - $chunks = 0; - - $response = $this->sendStream($client, $request, function (string $chunk) use ($hash, &$read, $baseline, &$peak, &$chunks): void { - $chunks++; - hash_update($hash, $chunk); - $read += \strlen($chunk); - $peak = max($peak, memory_get_usage() - $baseline); - }); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame($expected, $read); - $this->assertGreaterThan(1, $chunks, 'The final hop must be delivered in chunks, not as one assembled body.'); - $this->assertSame(hash('sha256', str_repeat('a', $expected)), hash_final($hash)); - $this->assertLessThan(2 * 1_048_576, $peak, 'Following a redirect must still stream the final body.'); - $this->assertSame('', (string) $response->getBody()); - }); - } - - public function testItStreamsPreservedMethodFinalHopAfterARedirect(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/redirect-stream-preserve'); - $client = $this->createAdapter()->withFollowRedirects(); - $received = ''; - $chunks = 0; - - $response = $this->sendStream($client, $request, function (string $chunk) use (&$received, &$chunks): void { - $received .= $chunk; - $chunks++; - }); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame("chunk0\nchunk1\nchunk2\nchunk3\nchunk4\n", $received); - $this->assertGreaterThan(1, $chunks, 'Redirected non-GET responses must reach the sink incrementally.'); - $this->assertSame('', (string) $response->getBody()); - }); - } - - public function testItPreservesDuplicateMixedCaseHeadersAndBinaryBodies(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $headers = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/headers')); - $binary = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/binary')); - - $this->assertSame(204, $headers->getStatusCode()); - $this->assertSame(['one', 'two'], $headers->getHeader('x-trace')); - $this->assertSame('Value', $headers->getHeaderLine('X-Mixed-Case')); - $this->assertSame(ContentType::OCTET_STREAM, $binary->getHeaderLine(Header::CONTENT_TYPE)); - $this->assertSame("\x00\x01hello\xff", (string) $binary->getBody()); - }); - } - - public function testItSendsExplicitHostAndRepeatedRequestHeaders(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory() - ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/request-headers') - ->withHeader(Header::HOST, 'proxy.example.test') - ->withHeader('X-Trace', ['one', 'two']); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('proxy.example.test:one, two', (string) $response->getBody()); - }); - } - - public function testItSendsDefaultHostWithNonDefaultPorts(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory() - ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/request-headers') - ->withHeader('X-Trace', 'sent'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('127.0.0.1:' . $port . ':sent', (string) $response->getBody()); - }); - } - - public function testItPreservesQueryStringsEmptyPathsAndStripsFragments(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $query = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/request-target?x=1&y=two#fragment')); - $emptyPath = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '?ping=1#fragment')); - $encoded = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/space%20name?value=a%2Bb#fragment')); - - $this->assertSame('/request-target?x=1&y=two', (string) $query->getBody()); - $this->assertSame('/?ping=1', (string) $emptyPath->getBody()); - $this->assertSame('/space%20name?value=a%2Bb', (string) $encoded->getBody()); - }); - } - - public function testItPreservesMethodsWithEmptyBodies(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $delete = $this->send($client, $requestFactory->createRequest(Method::DELETE, 'http://127.0.0.1:' . $port . '/method')); - $patch = $this->send($client, $requestFactory->createRequest(Method::PATCH, 'http://127.0.0.1:' . $port . '/method')); - $head = $this->send($client, $requestFactory->createRequest(Method::HEAD, 'http://127.0.0.1:' . $port . '/method')); - - $this->assertSame(Method::DELETE, $delete->getHeaderLine('X-Request-Method')); - $this->assertSame(Method::DELETE, (string) $delete->getBody()); - $this->assertSame(Method::PATCH, $patch->getHeaderLine('X-Request-Method')); - $this->assertSame(Method::PATCH, (string) $patch->getBody()); - $this->assertSame(Method::HEAD, $head->getHeaderLine('X-Request-Method')); - $this->assertSame('', (string) $head->getBody()); - }); - } - - public function testItPreservesCustomMethodsAndRequestBodies(): void - { - Http::serve(function (int $port): void { - $streamFactory = new Stream\Factory(); - $request = new Request\Factory() - ->createRequest('PROPFIND', 'http://127.0.0.1:' . $port . '/echo') - ->withHeader('X-Custom', 'sent') - ->withBody($streamFactory->createStream('custom-body')); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('PROPFIND:/echo:sent:custom-body', (string) $response->getBody()); - }); - } - - public function testItSendsBinaryRequestBodies(): void - { - Http::serve(function (int $port): void { - $body = "\x00\x01hello\xff"; - $streamFactory = new Stream\Factory(); - $request = new Request\Factory() - ->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/body-info') - ->withBody($streamFactory->createStream($body)); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(\strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); - }); - } - - public function testItPreservesCommaSeparatedAndZeroRequestHeaderValues(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory() - ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/selected-headers') - ->withHeader('X-Comma', 'one, two') - ->withHeader('X-Zero', '0') - ->withHeader('X-Mixed-Request', 'Value'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame('one, two:0:Value', (string) $response->getBody()); - }); - } - - public function testItUsesHttp11ProtocolVersion(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/binary'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame('1.1', $response->getProtocolVersion()); - }); - } - - public function testItParsesFinalResponseMetadata(): void - { - Http::raw("HTTP/1.1 201 Created Thing\r\nX-Trace: final\r\nX-Colon: http://example.test/a:b\r\nContent-Length: 7\r\n\r\ncreated", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/interim'); - - $response = $this->send($client, $request); - - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('1.1', $response->getProtocolVersion()); - $this->assertSame('final', $response->getHeaderLine('X-Trace')); - $this->assertSame('http://example.test/a:b', $response->getHeaderLine('X-Colon')); - $this->assertSame('created', (string) $response->getBody()); - }); - } - - public function testItPreservesRepeatedSetCookieResponseHeaders(): void - { - Http::raw("HTTP/1.1 200 OK\r\nSet-Cookie: a=1; Path=/\r\nSet-Cookie: b=2; Path=/; HttpOnly\r\nContent-Length: 2\r\n\r\nok", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/cookies'); - - $response = $this->send($client, $request); - - $this->assertSame(['a=1; Path=/', 'b=2; Path=/; HttpOnly'], $response->getHeader('Set-Cookie')); - $this->assertSame('ok', (string) $response->getBody()); - }); - } - - public function testItDecodesChunkedResponseBodies(): void - { - Http::raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/chunked'); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('hello world', (string) $response->getBody()); - }); - } - - public function testItReturnsEmptyBodiesForNoContentResponses(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/headers'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(204, $response->getStatusCode()); - $this->assertSame('', (string) $response->getBody()); - $this->assertSame(['one', 'two'], $response->getHeader('x-trace')); - }); - } - - public function testItRoundTripsLargeResponseBodies(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/large-response'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - $body = (string) $response->getBody(); - - $this->assertSame(262_144, \strlen($body)); - $this->assertSame(hash('sha256', str_repeat('abcd', 65_536)), hash('sha256', $body)); - }); - } - - public function testItRoundTripsLargeRequestBodies(): void - { - Http::serve(function (int $port): void { - $body = str_repeat('wxyz', 65_536); - $streamFactory = new Stream\Factory(); - $request = new Request\Factory() - ->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/body-info') - ->withBody($streamFactory->createStream($body)); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(\strlen($body) . ':' . hash('sha256', $body), (string) $response->getBody()); - }); - } - - public function testItSendsLargeRequestPayloads(): void - { - Http::serve(function (int $port): void { - $body = str_repeat('a', self::PAYLOAD_SIZE); - $streamFactory = new Stream\Factory(); - $request = new Request\Factory() - ->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/body-info') - ->withHeader(Header::CONTENT_TYPE, ContentType::OCTET_STREAM) - ->withBody($streamFactory->createStream($body)); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(self::PAYLOAD_SIZE . ':' . hash('sha256', $body), (string) $response->getBody()); - }); - } - - public function testItUploadsMultipartFilesAndFields(): void - { - Http::serve(function (int $port): void { - $contents = str_repeat('payload', 4096); - $path = $this->writeTempFile($contents); - - try { - $request = new Request\Factory()->multipart(Method::POST, 'http://127.0.0.1:' . $port . '/multipart', [ - 'name' => 'Ada', - 'file' => Part::file('file', $path, 'data.bin', ContentType::OCTET_STREAM), - ]); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('Ada:' . \strlen($contents) . ':' . hash('sha256', $contents), (string) $response->getBody()); - } finally { - unlink($path); - } - }); - } - - public function testTimeoutHelpersReturnConfiguredClones(): void - { - $client = $this->createAdapter(); - - $this->assertNotSame($client, $client->withTimeout(1)); - $this->assertNotSame($client, $client->withConnectTimeout(1)); - } - - public function testTlsHelpersReturnConfiguredClones(): void - { - $client = $this->createAdapter(); - - $this->assertNotSame($client, $client->withSslVerification(false)); - $this->assertNotSame($client, $client->withCustomCA('/etc/ssl/ca.pem')); - $this->assertNotSame($client, $client->withCertificate('/etc/ssl/client.pem', '/etc/ssl/client.key')); - $this->assertNotSame($client, $client->withCertificate('/etc/ssl/client.pem', '/etc/ssl/client.key', 'secret')); - $this->assertNotSame($client, $client->withMinTlsVersion(Tls::V1_2)); - } - - public function testFollowRedirectsHelperReturnsConfiguredClones(): void - { - $client = $this->createAdapter(); - - $this->assertNotSame($client, $client->withFollowRedirects()); - $this->assertNotSame($client, $client->withFollowRedirects(false)); - } - - public function testDefaultTimeoutsAllowReasonablySlowResponses(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter(); - - $response = $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/slow')); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('slow', (string) $response->getBody()); - }); - } - - public function testItRejectsInvalidResponseStatusCodes(): void - { - Http::raw("HTTP/1.1 999 Invalid\r\nContent-Length: 7\r\n\r\ninvalid", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/invalid'); - - $this->expectException(InvalidResponseException::class); - - $this->send($client, $request); - }); - } - - public function testItThrowsProtocolExceptionsForMalformedResponses(): void - { - Http::raw("not an http response\r\n\r\n", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/malformed'); - - $this->expectException(ProtocolException::class); - - $this->send($client, $request); - }); - } - - public function testItThrowsConnectionExceptionsWhenServerClosesBeforeResponse(): void - { - Http::raw('', function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/closed'); - - $this->expectException(ConnectionException::class); - - $this->send($client, $request); - }); - } - - public function testItThrowsConnectionExceptionsForPartialResponseHeaders(): void - { - Http::raw("HTTP/1.1 200 OK\r\nX-Partial: value", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/partial-headers'); - - $this->expectException(ConnectionException::class); - - $this->send($client, $request); - }); - } - - public function testItThrowsProtocolExceptionsForTruncatedBodies(): void - { - Http::raw("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/truncated-body'); - - $this->expectException(ProtocolException::class); - - $this->send($client, $request); - }); - } - - public function testItDoesNotReadABodyForHeadResponses(): void - { - // A HEAD response advertises the resource's Content-Length but carries - // no body. The adapter must not try to read that body: on a closed - // socket that surfaces as a truncated-body error, and on a kept-alive - // socket it wedges until the timeout. Either way the request must come - // back as a clean, empty-bodied 200. - Http::raw("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 4\r\n\r\n", function (int $port): void { - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - $request = new Request\Factory()->createRequest(Method::HEAD, 'http://127.0.0.1:' . $port . '/head'); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('4', $response->getHeaderLine('Content-Length')); - $this->assertSame('', (string) $response->getBody()); - }); - } - - public function testItThrowsConnectionExceptionsForConnectionFailures(): void - { - Http::unbound(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter($this->timeoutOptions(0.1, 0.1)); - - $this->expectException(ConnectionException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port)); - }); - } - - public function testItRecoversWhenAReusedConnectionIsDropped(): void - { - $statuses = []; - - $connections = Http::dropsFirstKeepAliveConnection(function (int $port) use (&$statuses): void { - $client = $this->createAdapter()->withConnectionReuse(); - $requestFactory = new Request\Factory(); - $uri = 'http://127.0.0.1:' . $port . '/'; - - // A reused connection only persists within a single coroutine run, - // so every request shares one runAdapter call. - $this->runAdapter(function () use ($client, $requestFactory, $uri, &$statuses): void { - for ($i = 0; $i < 4; $i++) { - $statuses[] = $client->sendRequest($requestFactory->createRequest(Method::GET, $uri))->getStatusCode(); - } - }); - }); - - // The peer dropped the first keep-alive socket; a reusing client must - // re-establish it and keep serving, not wedge on the dead connection. - $this->assertSame([200, 200, 200, 200], $statuses); - $this->assertSame(2, $connections); - } - - public function testItThrowsDnsExceptionsForResolutionFailures(): void - { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter($this->timeoutOptions(2, 1)); - - $this->expectException(DnsException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, 'http://utopia-request.invalid')); - } - - public function testItThrowsProxyExceptionsForProxyFailures(): void - { - Http::raw("\x04\x00", function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter($this->proxyOptions($port) + $this->timeoutOptions(1, 1)); - - $this->expectException(ProxyException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, 'http://example.com/')); - }); - } - - public function testItThrowsTlsExceptionsForTlsFailures(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter($this->timeoutOptions(1, 1)); - - $this->expectException(TlsException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, 'https://127.0.0.1:' . $port . '/binary')); - }); - } - - public function testItThrowsTimeoutExceptionsForTimedOutRequests(): void - { - Http::serve(function (int $port): void { - $requestFactory = new Request\Factory(); - $client = $this->createAdapter($this->timeoutOptions(0.1)); - - $this->expectException(TimeoutException::class); - - $this->send($client, $requestFactory->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/slow')); - }); - } - - public function testItRejectsInvalidTimeoutValues(): void - { - $client = $this->createAdapter(); - - $this->expectException(ValueError::class); - - $client->withTimeout(INF); - } - - public function testItRejectsInvalidConnectTimeoutValues(): void - { - $client = $this->createAdapter(); - - $this->expectException(ValueError::class); - - $client->withConnectTimeout(-0.001); - } - - public function testItRejectsInvalidAdapterConfigurationOptions(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter($this->invalidTransportOptions()); - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/binary'); - - $this->expectException(InvalidArgumentException::class); - - $this->send($client, $request); - }); - } - - public function testItSendsZeroStringBodiesAsNonEmptyBodies(): void - { - Http::serve(function (int $port): void { - $streamFactory = new Stream\Factory(); - $request = new Request\Factory() - ->createRequest(Method::PUT, 'http://127.0.0.1:' . $port . '/echo') - ->withHeader('X-Custom', 'sent') - ->withBody($streamFactory->createStream('0')); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('PUT:/echo:sent:0', (string) $response->getBody()); - }); - } - - public function testItAdvertisesCompressionAndTransparentlyDecodesResponses(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/gzip'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertStringContainsString('gzip', $response->getHeaderLine('X-Accept-Encoding')); - $this->assertSame(str_repeat('utopia ', 64), (string) $response->getBody()); - $this->assertFalse($response->hasHeader(Header::CONTENT_ENCODING)); - $this->assertFalse($response->hasHeader(Header::CONTENT_LENGTH)); - }); - } - - public function testItDecodesLargeCompressedResponses(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/gzip?repeat=40000'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - $body = (string) $response->getBody(); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame(40_000 * 7, \strlen($body)); - $this->assertSame(hash('sha256', str_repeat('utopia ', 40_000)), hash('sha256', $body)); - $this->assertFalse($response->hasHeader(Header::CONTENT_ENCODING)); - }); - } - - public function testItDecodesCompressedBinaryResponsesByteForByte(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/gzip?type=binary&repeat=16'); - $client = $this->createAdapter(); - - $expected = ''; - for ($byte = 0; $byte < 256; $byte++) { - $expected .= \chr($byte); - } - - $expected = str_repeat($expected, 16); - - $response = $this->send($client, $request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame($expected, (string) $response->getBody()); - $this->assertFalse($response->hasHeader(Header::CONTENT_ENCODING)); - }); - } - - public function testItLeavesUncompressedResponsesAndTheirContentLengthIntact(): void - { - Http::serve(function (int $port): void { - // Advertised-to but answered in plaintext: body and Content-Length pass through. - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/gzip?compress=0'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - $body = (string) $response->getBody(); - - $this->assertSame(str_repeat('utopia ', 64), $body); - $this->assertStringContainsString('gzip', $response->getHeaderLine('X-Accept-Encoding')); - $this->assertFalse($response->hasHeader(Header::CONTENT_ENCODING)); - $this->assertSame((string) \strlen($body), $response->getHeaderLine(Header::CONTENT_LENGTH)); - }); - } - - public function testItDeliversDecodedBodiesWhenStreaming(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/gzip?repeat=20000'); - $client = $this->createAdapter(); - - $received = ''; - - $response = $this->sendStream($client, $request, function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - // cURL decodes the stream; Swoole asks for identity. Either way the sink sees plaintext. - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame(str_repeat('utopia ', 20_000), $received); - $this->assertSame('', (string) $response->getBody()); - }); - } - - public function testItDecodesCompressedResponsesOverAReusedConnection(): void - { - $bodies = []; - - Http::serve(function (int $port) use (&$bodies): void { - $client = $this->createAdapter()->withConnectionReuse(); - $requestFactory = new Request\Factory(); - $uri = 'http://127.0.0.1:' . $port . '/gzip'; - - // A reused connection only persists within one coroutine run. - $this->runAdapter(function () use ($client, $requestFactory, $uri, &$bodies): void { - for ($i = 0; $i < 3; $i++) { - $bodies[] = (string) $client->sendRequest($requestFactory->createRequest(Method::GET, $uri))->getBody(); - } - }); - }); - - $this->assertSame(array_fill(0, 3, str_repeat('utopia ', 64)), $bodies); - } - - public function testItDoesNotOverrideAnExplicitAcceptEncoding(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory() - ->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/gzip') - ->withHeader(Header::ACCEPT_ENCODING, 'identity'); - $client = $this->createAdapter(); - - $response = $this->send($client, $request); - - // The caller's identity is left untouched, so the server stays plaintext. - $this->assertSame('identity', $response->getHeaderLine('X-Accept-Encoding')); - $this->assertSame(str_repeat('utopia ', 64), (string) $response->getBody()); - $this->assertFalse($response->hasHeader(Header::CONTENT_ENCODING)); - }); - } - - public function testItStreamsResponseBodiesToASink(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/stream'); - $client = $this->createAdapter(); - - $received = ''; - - $response = $this->sendStream($client, $request, function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame("chunk0\nchunk1\nchunk2\nchunk3\nchunk4\n", $received); - $this->assertSame('', (string) $response->getBody()); - }); - } - - public function testItStreamsLargeResponsesWithBoundedMemory(): void - { - Http::serve(function (int $port): void { - $request = new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/stream-large'); - $client = $this->createAdapter(); - - $expected = self::PAYLOAD_SIZE; - $hash = hash_init('sha256'); - $read = 0; - $baseline = memory_get_usage(); - $peak = 0; - - $response = $this->sendStream($client, $request, function (string $chunk) use ($hash, &$read, $baseline, &$peak): void { - hash_update($hash, $chunk); - $read += \strlen($chunk); - $peak = max($peak, memory_get_usage() - $baseline); - }); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame($expected, $read); - $this->assertSame(hash('sha256', str_repeat('a', $expected)), hash_final($hash)); - $this->assertLessThan(2 * 1_048_576, $peak, 'Streaming must not buffer the whole body.'); - }); - } - - protected function writeTempFile(string $contents): string - { - $path = tempnam(sys_get_temp_dir(), 'utopia-upload-'); - - if ($path === false) { - self::fail('Unable to create a temporary upload file.'); - } - - file_put_contents($path, $contents); - - return $path; - } - - /** - * Write a file of $size bytes without holding it in memory, so it can be - * uploaded to prove streaming keeps memory bounded. - */ - protected function writeLargeTempFile(int $size): string - { - $path = tempnam(sys_get_temp_dir(), 'utopia-upload-'); - - if ($path === false) { - self::fail('Unable to create a temporary upload file.'); - } - - $handle = fopen($path, 'wb'); - - if (!\is_resource($handle)) { - self::fail('Unable to open the temporary upload file.'); - } - - $chunk = str_repeat('a', 65_536); - - for ($written = 0; $written < $size; $written += \strlen($chunk)) { - fwrite($handle, $chunk); - } - - fclose($handle); - - return $path; - } - - private function send(Adapter $client, RequestInterface $request): ResponseInterface - { - $response = null; - $thrown = null; - - $this->runAdapter(function () use ($client, $request, &$response, &$thrown): void { - try { - $response = $client->sendRequest($request); - } catch (Throwable $throwable) { - $thrown = $throwable; - } - }); - - if ($thrown instanceof Throwable) { - throw $thrown; - } - - if (!$response instanceof ResponseInterface) { - self::fail('Adapter did not return a response.'); - } - - return $response; - } - - /** - * @param callable(string): void $sink - */ - private function sendStream(Adapter $client, RequestInterface $request, callable $sink): ResponseInterface - { - $response = null; - $thrown = null; - - $this->runAdapter(function () use ($client, $request, $sink, &$response, &$thrown): void { - try { - $response = $client->stream($request, $sink); - } catch (Throwable $throwable) { - $thrown = $throwable; - } - }); - - if ($thrown instanceof Throwable) { - throw $thrown; - } - - if (!$response instanceof ResponseInterface) { - self::fail('Adapter did not return a response.'); - } - - return $response; - } -} diff --git a/packages/client/tests/Adapter/Curl/ClientTest.php b/packages/client/tests/Adapter/Curl/ClientTest.php deleted file mode 100644 index 3ceaac7f7..000000000 --- a/packages/client/tests/Adapter/Curl/ClientTest.php +++ /dev/null @@ -1,145 +0,0 @@ -writeLargeTempFile($size); - - try { - Http::serve(function (int $port) use ($path, $size): void { - $expected = $size . ':' . hash_file('sha256', $path); - $request = new Request\Factory() - ->createRequest(Method::POST, 'http://127.0.0.1:' . $port . '/body-info') - ->withBody(new Stream\Factory()->createStreamFromFile($path)); - - $peak = $this->peakWhileSending($request); - - $this->assertSame($expected, $peak['body']); - $this->assertLessThan(2 * 1_048_576, $peak['peak'], 'Uploading must not buffer the whole body.'); - }); - } finally { - unlink($path); - } - } - - public function testItStreamsLargeMultipartUploadsWithBoundedMemory(): void - { - $size = 8 * 1024 * 1024; - $path = $this->writeLargeTempFile($size); - - try { - Http::serve(function (int $port) use ($path, $size): void { - $request = new Request\Factory()->multipart(Method::POST, 'http://127.0.0.1:' . $port . '/multipart', [ - 'name' => 'Ada', - 'file' => Part::file('file', $path, 'data.bin', ContentType::OCTET_STREAM), - ]); - - $peak = $this->peakWhileSending($request); - - $this->assertSame('Ada:' . $size . ':' . hash_file('sha256', $path), $peak['body']); - $this->assertLessThan(2 * 1_048_576, $peak['peak'], 'Multipart uploads must not buffer the whole file.'); - }); - } finally { - unlink($path); - } - } - - /** - * @return array{body: string, peak: int} - */ - private function peakWhileSending(RequestInterface $request): array - { - $baseline = memory_get_usage(); - memory_reset_peak_usage(); - - $response = $this->createAdapter()->sendRequest($request); - - return [ - 'body' => (string) $response->getBody(), - 'peak' => memory_get_peak_usage() - $baseline, - ]; - } - - /** - * @param array $transportOptions - */ - protected function createAdapter(array $transportOptions = []): Adapter - { - return new Client(new Response\Factory(), new Stream\Factory(), $transportOptions); - } - - protected function runAdapter(callable $callback): void - { - $callback(); - } - - protected function requireAdapterAvailable(): void - { - $this->assertTrue(\extension_loaded('curl'), 'The curl extension is required.'); - } - - /** - * @return array - */ - protected function invalidTransportOptions(): array - { - return [ - 999_999_999 => true, - ]; - } - - /** - * @return array - */ - protected function timeoutOptions(float $timeout, ?float $connectTimeout = null): array - { - $options = [ - \CURLOPT_TIMEOUT_MS => (int) round($timeout * 1000), - ]; - - if ($connectTimeout !== null) { - $options[\CURLOPT_CONNECTTIMEOUT_MS] = (int) round($connectTimeout * 1000); - } - - return $options; - } - - /** - * @return array - */ - protected function proxyOptions(int $port): array - { - return [ - \CURLOPT_PROXY => '127.0.0.1:' . $port, - \CURLOPT_PROXYTYPE => \CURLPROXY_SOCKS5, - ]; - } - - /** - * @return array - */ - protected function followRedirectsTransportOptions(bool $enabled): array - { - return [ - \CURLOPT_FOLLOWLOCATION => $enabled, - ]; - } -} diff --git a/packages/client/tests/Adapter/SwooleCoroutine/ClientTest.php b/packages/client/tests/Adapter/SwooleCoroutine/ClientTest.php deleted file mode 100644 index aeb0f8c9d..000000000 --- a/packages/client/tests/Adapter/SwooleCoroutine/ClientTest.php +++ /dev/null @@ -1,285 +0,0 @@ - $transportOptions - */ - protected function createAdapter(array $transportOptions = []): Adapter - { - return new Client(new Response\Factory(), new Stream\Factory(), $transportOptions); - } - - protected function runAdapter(callable $callback): void - { - $failure = null; - Coroutine\run(static function () use ($callback, &$failure): void { - try { - $callback(); - } catch (Throwable $throwable) { - $failure = $throwable; - } - }); - if ($failure instanceof Throwable) { - throw $failure; - } - } - - public function testItReconnectsBeforePostingToAnAbruptlyClosedIdleTlsConnection(): void - { - $connections = Http::dropsFirstKeepAliveConnection(function (int $port): void { - $client = $this->createAdapter()->withConnectionReuse()->withSslVerification(false); - Coroutine\run(function () use ($client, $port): void { - for ($i = 0; $i < 4; $i++) { - $body = 'event-' . $i; - $request = new Request\Factory()->createRequest(Method::POST, 'https://127.0.0.1:' . $port . '/') - ->withHeader('Content-Length', (string) \strlen($body)) - ->withBody(new Stream\Factory()->createStream($body)); - $response = $client->sendRequest($request); - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame($body, (string) $response->getBody()); - Coroutine::sleep(0.05); - } - }); - }, tls: true); - - $this->assertSame(2, $connections); - } - - public function testItDoesNotReplayAPostWhenThePeerDropsItsResponse(): void - { - $thrown = null; - $connections = Http::dropsFirstKeepAliveConnection(function (int $port) use (&$thrown): void { - $client = $this->createAdapter()->withConnectionReuse()->withSslVerification(false); - Coroutine\run(function () use ($client, $port, &$thrown): void { - $factory = new Request\Factory(); - $uri = 'https://127.0.0.1:' . $port . '/'; - $this->assertSame(200, $client->sendRequest($factory->createRequest(Method::GET, $uri))->getStatusCode()); - $request = $factory->createRequest(Method::POST, $uri) - ->withHeader('Content-Length', '5') - ->withBody(new Stream\Factory()->createStream('event')); - try { - $client->sendRequest($request); - } catch (Throwable $throwable) { - $thrown = $throwable; - } - }); - }, tls: true, dropResponse: true); - - $this->assertInstanceOf(NetworkException::class, $thrown); - $this->assertSame(1, $connections); - } - - public function testItRejectsATrustedCertificateForAnotherHostname(): void - { - Http::dropsFirstKeepAliveConnection(function (int $port, string $certificate): void { - $client = $this->createAdapter(['ssl_host_name' => 'localhost'])->withCustomCA($certificate)->withConnectionReuse(); - $this->runAdapter(function () use ($client, $port): void { - $factory = new Request\Factory(); - $matching = $factory->createRequest(Method::GET, 'https://localhost:' . $port . '/'); - $this->assertSame('ok', (string) $client->sendRequest($matching)->getBody()); - - $request = $factory->createRequest(Method::GET, 'https://localhost.:' . $port . '/private') - ->withHeader('Host', 'localhost'); - $thrown = null; - try { - $client->sendRequest($request); - } catch (Throwable $throwable) { - $thrown = $throwable; - } - - $this->assertInstanceOf(TlsException::class, $thrown); - $this->assertSame('ok', (string) $client->sendRequest($matching)->getBody()); - }); - }, tls: true, peerName: 'localhost', receivedRequests: $requests); - - $this->assertCount(2, $requests); - foreach ($requests as $request) { - $this->assertStringStartsWith('GET / HTTP/1.1', $request); - } - } - - public function testItRejectsVerifiedIpLiterals(): void - { - Http::dropsFirstKeepAliveConnection(function (int $port): void { - $client = $this->createAdapter()->withSslVerification(); - $this->runAdapter(function () use ($client, $port): void { - foreach (['127.0.0.1', '[::1]'] as $host) { - $request = new Request\Factory()->createRequest(Method::GET, 'https://' . $host . ':' . $port . '/'); - $thrown = null; - try { - $client->sendRequest($request); - } catch (Throwable $throwable) { - $thrown = $throwable; - } - - $this->assertInstanceOf(TlsException::class, $thrown); - } - }); - }, tls: true, receivedRequests: $requests); - - $this->assertSame([], $requests); - } - - public function testABufferedRequestAfterAStreamedOneOnAReusedConnectionGetsItsBody(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter()->withConnectionReuse(); - $streamed = ''; - $sinkCalls = 0; - - $this->runAdapter(function () use ($client, $port, &$streamed, &$sinkCalls): void { - $response = $client->stream( - new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/stream'), - function (string $chunk) use (&$streamed, &$sinkCalls): void { - $streamed .= $chunk; - $sinkCalls++; - }, - ); - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame("chunk0\nchunk1\nchunk2\nchunk3\nchunk4\n", $streamed); - - $callsAfterStream = $sinkCalls; - - // The write callback the stream installed must not swallow the next buffered body. - $response = $client->sendRequest(new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/buffered')); - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('GET:/buffered::', (string) $response->getBody()); - $this->assertSame($callsAfterStream, $sinkCalls, 'the stale sink received nothing'); - - // And a stream after that still reaches its own sink. - $again = ''; - $client->stream( - new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/stream'), - function (string $chunk) use (&$again): void { - $again .= $chunk; - }, - ); - $this->assertSame($streamed, $again); - }); - }); - } - - public function testABufferedRequestIgnoresAWriteCallbackPassedInSettings(): void - { - Http::serve(function (int $port): void { - $calls = 0; - $client = $this->createAdapter(['write_func' => function () use (&$calls): void { - $calls++; - }]); - - $this->runAdapter(function () use ($client, $port, &$calls): void { - $response = $client->sendRequest(new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/buffered')); - $this->assertSame(202, $response->getStatusCode()); - $this->assertSame('GET:/buffered::', (string) $response->getBody()); - $this->assertSame(0, $calls, 'the supplied write callback received nothing'); - }); - }); - } - - public function testAReusedStreamConnectionReleasesItsSinkAfterTheStream(): void - { - Http::serve(function (int $port): void { - $client = $this->createAdapter()->withConnectionReuse(); - $captured = null; - - $this->runAdapter(function () use ($client, $port, &$captured): void { - $held = new \stdClass(); - $captured = \WeakReference::create($held); - - $response = $client->stream( - new Request\Factory()->createRequest(Method::GET, 'http://127.0.0.1:' . $port . '/stream'), - function (string $chunk) use ($held): void { - $held->last = $chunk; - }, - ); - $this->assertSame(200, $response->getStatusCode()); - }); - - gc_collect_cycles(); - - $this->assertInstanceOf(\WeakReference::class, $captured); - $this->assertNotInstanceOf(\stdClass::class, $captured->get(), 'the sink outlived the stream on the kept-alive connection'); - }); - } - - public function testItRequiresCoroutineContext(): void - { - $client = $this->createAdapter(); - $request = new Request\Factory()->createRequest(Method::GET, 'https://example.com'); - - $this->expectException(AdapterPreconditionException::class); - - $client->sendRequest($request); - } - - protected function requireAdapterAvailable(): void - { - $this->assertTrue(\extension_loaded('swoole'), 'The swoole extension is required.'); - } - - /** - * @return array - */ - protected function invalidTransportOptions(): array - { - return [ - 'timeout' => [], - ]; - } - - /** - * @return array - */ - protected function timeoutOptions(float $timeout, ?float $connectTimeout = null): array - { - $options = [ - 'timeout' => $timeout, - ]; - - if ($connectTimeout !== null) { - $options['connect_timeout'] = $connectTimeout; - } - - return $options; - } - - /** - * @return array - */ - protected function proxyOptions(int $port): array - { - return [ - 'socks5_host' => '127.0.0.1', - 'socks5_port' => $port, - ]; - } - - /** - * @return array - */ - protected function followRedirectsTransportOptions(bool $enabled): array - { - return [ - 'follow_location' => $enabled, - ]; - } -} diff --git a/packages/client/tests/Adapter/TimeoutTest.php b/packages/client/tests/Adapter/TimeoutTest.php deleted file mode 100644 index 8a28b200d..000000000 --- a/packages/client/tests/Adapter/TimeoutTest.php +++ /dev/null @@ -1,33 +0,0 @@ -expectException(ValueError::class); - - $adapter->withTimeout(\INF); - } - - public function testSwooleTimeoutsRejectInvalidValues(): void - { - $adapter = new SwooleClient(new Response\Factory(), new Stream\Factory()); - - $this->expectException(ValueError::class); - - $adapter->withConnectTimeout(-0.001); - } -} diff --git a/packages/client/tests/ClientTest.php b/packages/client/tests/ClientTest.php deleted file mode 100644 index 8d04b920c..000000000 --- a/packages/client/tests/ClientTest.php +++ /dev/null @@ -1,388 +0,0 @@ -createRequest('GET', 'https://example.com'); - $adapter = new RecordingAdapter(); - $client = new Client($adapter); - $configured = $client - ->withTimeout(5.5) - ->withConnectTimeout(1.25); - - $response = $configured->sendRequest($request); - - $this->assertSame('', $client->sendRequest($request)->getHeaderLine('X-Timeout')); - $this->assertSame('5.5', $response->getHeaderLine('X-Timeout')); - $this->assertSame('1.25', $response->getHeaderLine('X-Connect-Timeout')); - } - - public function testItDecoratesTlsConfiguration(): void - { - $request = new Request\Factory()->createRequest('GET', 'https://example.com'); - $client = new Client(new RecordingAdapter()); - $configured = $client - ->withSslVerification(false) - ->withCustomCA('/etc/ssl/ca.pem') - ->withCertificate('/etc/ssl/client.pem', '/etc/ssl/client.key', 'secret') - ->withMinTlsVersion(Tls::V1_2); - - $response = $configured->sendRequest($request); - - $this->assertSame('', $client->sendRequest($request)->getHeaderLine('X-Tls-Verify')); - $this->assertSame('off', $response->getHeaderLine('X-Tls-Verify')); - $this->assertSame('/etc/ssl/ca.pem', $response->getHeaderLine('X-Tls-Ca')); - $this->assertSame('/etc/ssl/client.pem:/etc/ssl/client.key:secret', $response->getHeaderLine('X-Tls-Cert')); - $this->assertSame('V1_2', $response->getHeaderLine('X-Tls-Min-Version')); - } - - public function testItDecoratesConnectionReuse(): void - { - $request = new Request\Factory()->createRequest('GET', 'https://example.com'); - $client = new Client(new RecordingAdapter()); - $configured = $client->withConnectionReuse(); - - $this->assertSame('', $client->sendRequest($request)->getHeaderLine('X-Connection-Reuse')); - $this->assertSame('on', $configured->sendRequest($request)->getHeaderLine('X-Connection-Reuse')); - $this->assertSame('off', $client->withConnectionReuse(false)->sendRequest($request)->getHeaderLine('X-Connection-Reuse')); - } - - public function testItDecoratesFollowRedirects(): void - { - $request = new Request\Factory()->createRequest('GET', 'https://example.com'); - $client = new Client(new RecordingAdapter()); - $configured = $client->withFollowRedirects(); - - $this->assertSame('', $client->sendRequest($request)->getHeaderLine('X-Follow-Redirects')); - $this->assertSame('on', $configured->sendRequest($request)->getHeaderLine('X-Follow-Redirects')); - $this->assertSame('off', $client->withFollowRedirects(false)->sendRequest($request)->getHeaderLine('X-Follow-Redirects')); - } - - public function testItRejectsInvalidTimeouts(): void - { - $client = new Client(new RecordingAdapter()); - - $this->expectException(ValueError::class); - - $client->withTimeout(-1); - } - - public function testItAppliesDefaultHeadersImmutablyWithoutOverridingRequestHeaders(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new RecordingAdapter()); - $configured = $client->withHeaders([ - 'Accept' => 'application/json', - 'X-Trace' => ['one', 'two'], - ]); - - $plain = $client->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com'), - ); - $response = $configured->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com') - ->withHeader('Accept', 'application/xml'), - ); - - $this->assertSame('', $plain->getHeaderLine('X-Request-Accept')); - $this->assertSame('application/xml', $response->getHeaderLine('X-Request-Accept')); - $this->assertSame('one, two', $response->getHeaderLine('X-Request-Trace')); - } - - public function testItAppliesAuthDefaultsWithoutOverridingRequestAuthorization(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new RecordingAdapter()); - - $basic = $client - ->withBasicAuth('ada', 'secret') - ->sendRequest($requestFactory->createRequest('GET', 'https://example.com')); - $bearer = $client - ->withBearerAuth('token') - ->sendRequest($requestFactory->createRequest('GET', 'https://example.com')); - $override = $client - ->withBearerAuth('token') - ->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com') - ->withHeader('Authorization', 'Digest custom'), - ); - - $this->assertSame('Basic YWRhOnNlY3JldA==', $basic->getHeaderLine('X-Request-Authorization')); - $this->assertSame('Bearer token', $bearer->getHeaderLine('X-Request-Authorization')); - $this->assertSame('Digest custom', $override->getHeaderLine('X-Request-Authorization')); - } - - public function testItAppliesBaseUriToRelativeRequests(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new RecordingAdapter()) - ->withBaseUri('https://api.example.com/v1'); - - $relative = $client->sendRequest( - $requestFactory->createRequest('GET', 'users?active=1'), - ); - $absolutePath = $client->sendRequest( - $requestFactory->createRequest('GET', '/status'), - ); - $absoluteUri = $client->sendRequest( - $requestFactory->createRequest('GET', 'https://other.example.com/users'), - ); - - $this->assertSame('https://api.example.com/v1/users?active=1', $relative->getHeaderLine('X-Request-Uri')); - $this->assertSame('api.example.com', $relative->getHeaderLine('X-Request-Host')); - $this->assertSame('https://api.example.com/status', $absolutePath->getHeaderLine('X-Request-Uri')); - $this->assertSame('https://other.example.com/users', $absoluteUri->getHeaderLine('X-Request-Uri')); - } - - public function testItRejectsRelativeBaseUris(): void - { - $client = new Client(new RecordingAdapter()); - - $this->expectException(InvalidArgumentException::class); - - $client->withBaseUri('/api'); - } - - public function testItPropagatesTheActiveTraceWithoutOverridingAnInboundOne(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new RecordingAdapter())->withTracePropagation(); - - Span::setStorage(new Memory()); - $span = Span::init('http.request'); - - try { - $propagated = $client->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com'), - ); - $forwarded = $client->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com') - ->withHeader('traceparent', 'incoming'), - ); - - $this->assertSame($span->getTraceparent(), $propagated->getHeaderLine('X-Request-Traceparent')); - $this->assertSame('incoming', $forwarded->getHeaderLine('X-Request-Traceparent')); - } finally { - $span->finish(); - Span::setStorage(null); - } - } - - public function testItDoesNotPropagateTracesByDefault(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new RecordingAdapter()); - - Span::setStorage(new Memory()); - $span = Span::init('http.request'); - - try { - $response = $client->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com'), - ); - - $this->assertSame('', $response->getHeaderLine('X-Request-Traceparent')); - } finally { - $span->finish(); - Span::setStorage(null); - } - } - - public function testItLeavesRequestsUntouchedWithoutAnActiveSpan(): void - { - $requestFactory = new Request\Factory(); - $client = new Client(new RecordingAdapter())->withTracePropagation(); - - $response = $client->sendRequest( - $requestFactory->createRequest('GET', 'https://example.com'), - ); - - $this->assertSame('', $response->getHeaderLine('X-Request-Traceparent')); - } - - public function testItStreamsThroughTheAdapterApplyingBaseUriAndHeaders(): void - { - $requestFactory = new Request\Factory(); - $received = ''; - $client = new Client(new RecordingAdapter()) - ->withBaseUri('https://api.example.com/v1') - ->withHeaders(['Accept' => 'application/json']); - - $response = $client->stream( - $requestFactory->createRequest('GET', 'users'), - function (string $chunk) use (&$received): void { - $received .= $chunk; - }, - ); - - $this->assertSame('chunk', $received); - $this->assertSame('https://api.example.com/v1/users', $response->getHeaderLine('X-Request-Uri')); - $this->assertSame('application/json', $response->getHeaderLine('X-Request-Accept')); - } -} - -final class RecordingAdapter implements Adapter -{ - public function __construct( - private ?float $timeout = null, - private ?float $connectTimeout = null, - private ?bool $sslVerification = null, - private ?string $customCA = null, - private ?string $certificate = null, - private ?Tls $minTlsVersion = null, - private ?bool $connectionReuse = null, - private ?bool $followRedirects = null, - ) {} - - public function withTimeout(float $seconds): static - { - if ($seconds < 0.0 || !is_finite($seconds)) { - throw new ValueError('Timeout must be a finite number greater than or equal to zero.'); - } - - $clone = clone $this; - $clone->timeout = $seconds; - - return $clone; - } - - public function withConnectTimeout(float $seconds): static - { - if ($seconds < 0.0 || !is_finite($seconds)) { - throw new ValueError('Timeout must be a finite number greater than or equal to zero.'); - } - - $clone = clone $this; - $clone->connectTimeout = $seconds; - - return $clone; - } - - public function withSslVerification(bool $enabled = true): static - { - $clone = clone $this; - $clone->sslVerification = $enabled; - - return $clone; - } - - public function withCustomCA(string $path): static - { - $clone = clone $this; - $clone->customCA = $path; - - return $clone; - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - $clone = clone $this; - $clone->certificate = $certPath . ':' . $keyPath . ($passphrase === null ? '' : ':' . $passphrase); - - return $clone; - } - - public function withMinTlsVersion(Tls $version): static - { - $clone = clone $this; - $clone->minTlsVersion = $version; - - return $clone; - } - - public function withConnectionReuse(bool $enabled = true): static - { - $clone = clone $this; - $clone->connectionReuse = $enabled; - - return $clone; - } - - public function withFollowRedirects(bool $enabled = true): static - { - $clone = clone $this; - $clone->followRedirects = $enabled; - - return $clone; - } - - /** - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - $response = new Response\Factory() - ->createResponse() - ->withHeader('X-Request-Uri', (string) $request->getUri()) - ->withHeader('X-Request-Host', $request->getHeaderLine('Host')) - ->withHeader('X-Request-Accept', $request->getHeaderLine('Accept')) - ->withHeader('X-Request-Authorization', $request->getHeaderLine('Authorization')) - ->withHeader('X-Request-Trace', $request->getHeaderLine('X-Trace')) - ->withHeader('X-Request-Traceparent', $request->getHeaderLine('traceparent')); - - if ($this->timeout !== null) { - $response = $response->withHeader('X-Timeout', (string) $this->timeout); - } - - if ($this->sslVerification !== null) { - $response = $response->withHeader('X-Tls-Verify', $this->sslVerification ? 'on' : 'off'); - } - - if ($this->customCA !== null) { - $response = $response->withHeader('X-Tls-Ca', $this->customCA); - } - - if ($this->certificate !== null) { - $response = $response->withHeader('X-Tls-Cert', $this->certificate); - } - - if ($this->minTlsVersion instanceof \Utopia\Client\Tls) { - $response = $response->withHeader('X-Tls-Min-Version', $this->minTlsVersion->name); - } - - if ($this->connectionReuse !== null) { - $response = $response->withHeader('X-Connection-Reuse', $this->connectionReuse ? 'on' : 'off'); - } - - if ($this->followRedirects !== null) { - $response = $response->withHeader('X-Follow-Redirects', $this->followRedirects ? 'on' : 'off'); - } - - if ($this->connectTimeout !== null) { - return $response->withHeader('X-Connect-Timeout', (string) $this->connectTimeout); - } - - return $response; - } - - /** - * @param callable(string): void $sink - * - * @throws ClientExceptionInterface - */ - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - $sink('chunk'); - - return $this->sendRequest($request); - } -} diff --git a/packages/client/tests/CompatTest.php b/packages/client/tests/CompatTest.php deleted file mode 100644 index 976a32114..000000000 --- a/packages/client/tests/CompatTest.php +++ /dev/null @@ -1,49 +0,0 @@ -fail($old . ' does not resolve'); - } - - $this->assertSame(Client::class, new \ReflectionClass($old)->getName()); - } - - public function testOldStreamingInterfaceNameResolvesToTheMovedInterface(): void - { - $old = \Utopia\Psr18\StreamingClientInterface::class; - if (!interface_exists($old)) { - $this->fail($old . ' does not resolve'); - } - - $this->assertSame(StreamingClientInterface::class, new \ReflectionClass($old)->getName()); - } - - /** - * PHP never autoloads a name while checking a declared type, so a - * consumer typed against an old name accepts the moved classes only if - * the alias exists before any code mentions it. A fresh process keeps - * the earlier tests from registering it first. - */ - #[RunInSeparateProcess] - public function testOldNamesInTypeDeclarationsAcceptTheMovedClasses(): void - { - $consumer = new OldNamesConsumer(new Client(new CurlClient())); - - $this->assertInstanceOf(Client::class, $consumer->client); - $this->assertInstanceOf(StreamingClientInterface::class, $consumer->stream); - } -} diff --git a/packages/client/tests/Decorator/Retry/BackoffTest.php b/packages/client/tests/Decorator/Retry/BackoffTest.php deleted file mode 100644 index 6d8ee57f5..000000000 --- a/packages/client/tests/Decorator/Retry/BackoffTest.php +++ /dev/null @@ -1,112 +0,0 @@ -request(Method::GET); - - $delay = $this->strategy()->delay($request, 1, null, new NetworkException($request, 'reset')); - - $this->assertEqualsWithDelta(0.1, $delay, PHP_FLOAT_EPSILON); - } - - public function testItDoesNotRetryRequestExceptions(): void - { - $request = $this->request(Method::GET); - - $this->assertNull($this->strategy()->delay($request, 1, null, new InvalidUriException($request, 'bad'))); - } - - public function testItDoesNotRetryNonIdempotentMethods(): void - { - $request = $this->request(Method::POST); - - $this->assertNull($this->strategy()->delay($request, 1, null, new NetworkException($request, 'reset'))); - } - - public function testItDoesNotRetrySuccessfulResponses(): void - { - $request = $this->request(Method::GET); - - $this->assertNull($this->strategy()->delay($request, 1, new Response(200), null)); - } - - public function testItRetriesOverloadedStatusResponses(): void - { - $request = $this->request(Method::GET); - - $this->assertEqualsWithDelta(0.1, $this->strategy()->delay($request, 1, new Response(503), null), PHP_FLOAT_EPSILON); - } - - public function testItStopsAtMaxAttempts(): void - { - $request = $this->request(Method::GET); - - $this->assertNull($this->strategy()->delay($request, 3, null, new NetworkException($request, 'reset'))); - } - - public function testItGrowsTheDelayExponentially(): void - { - $request = $this->request(Method::GET); - $strategy = $this->strategy(); - $error = new NetworkException($request, 'reset'); - - $this->assertEqualsWithDelta(0.1, $strategy->delay($request, 1, null, $error), PHP_FLOAT_EPSILON); - $this->assertEqualsWithDelta(0.2, $strategy->delay($request, 2, null, $error), PHP_FLOAT_EPSILON); - } - - public function testItHonoursNumericRetryAfterCappedToMaxDelay(): void - { - $request = $this->request(Method::GET); - $response = new Response(503)->withHeader('Retry-After', '999'); - - $this->assertEqualsWithDelta(10.0, $this->strategy()->delay($request, 1, $response, null), PHP_FLOAT_EPSILON); - } - - public function testItIgnoresNonNumericRetryAfter(): void - { - $request = $this->request(Method::GET); - $response = new Response(503)->withHeader('Retry-After', 'Wed, 21 Oct 2025 07:28:00 GMT'); - - $this->assertEqualsWithDelta(0.1, $this->strategy()->delay($request, 1, $response, null), PHP_FLOAT_EPSILON); - } - - public function testItAppliesFullJitterWithinTheCeiling(): void - { - $request = $this->request(Method::GET); - $strategy = new Backoff(); // real randomizer - $error = new NetworkException($request, 'reset'); - - for ($i = 0; $i < 50; $i++) { - $delay = $strategy->delay($request, 1, null, $error); - - $this->assertNotNull($delay); - $this->assertGreaterThanOrEqual(0.0, $delay); - $this->assertLessThanOrEqual(0.1, $delay); - } - } - - private function strategy(): Backoff - { - return new Backoff(randomizer: static fn(): float => 1.0); - } - - private function request(string $method): RequestInterface - { - return new Request\Factory()->createRequest($method, 'https://example.com/resource'); - } -} diff --git a/packages/client/tests/Decorator/RetryTest.php b/packages/client/tests/Decorator/RetryTest.php deleted file mode 100644 index 146cc3996..000000000 --- a/packages/client/tests/Decorator/RetryTest.php +++ /dev/null @@ -1,257 +0,0 @@ -request(Method::GET); - $inner = new QueueAdapter([ - fn() => throw new NetworkException($request, 'reset'), - fn() => throw new NetworkException($request, 'reset'), - fn(): \Utopia\Psr7\Response => new Response(200), - ]); - $delays = []; - - $response = $this->retry($inner, $delays)->sendRequest($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame(3, $inner->calls); - $this->assertSame([0.1, 0.2], $delays); - } - - public function testItStopsAndRethrowsAfterExhaustingAttempts(): void - { - $request = $this->request(Method::GET); - $inner = new QueueAdapter(array_fill(0, 3, fn() => throw new NetworkException($request, 'reset'))); - $delays = []; - - try { - $this->retry($inner, $delays)->sendRequest($request); - $this->fail('Expected the final failure to be rethrown.'); - } catch (NetworkException) { - $this->assertSame(3, $inner->calls); - $this->assertSame([0.1, 0.2], $delays); - } - } - - public function testItDoesNotRetryRequestExceptions(): void - { - $request = $this->request(Method::GET); - $inner = new QueueAdapter([fn() => throw new InvalidUriException($request, 'bad')]); - $delays = []; - - $this->expectException(InvalidUriException::class); - - try { - $this->retry($inner, $delays)->sendRequest($request); - } finally { - $this->assertSame(1, $inner->calls); - $this->assertSame([], $delays); - } - } - - public function testItDoesNotRetryNonIdempotentMethods(): void - { - $request = $this->request(Method::POST); - $inner = new QueueAdapter([fn() => throw new NetworkException($request, 'reset')]); - $delays = []; - - $this->expectException(NetworkException::class); - - try { - $this->retry($inner, $delays)->sendRequest($request); - } finally { - $this->assertSame(1, $inner->calls); - } - } - - public function testItRetriesOverloadedStatusResponses(): void - { - $request = $this->request(Method::GET); - $inner = new QueueAdapter([ - fn(): \Utopia\Psr7\Response => new Response(503), - fn(): \Utopia\Psr7\Response => new Response(200), - ]); - $delays = []; - - $response = $this->retry($inner, $delays)->sendRequest($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame(2, $inner->calls); - $this->assertSame([0.1], $delays); - } - - public function testItRetriesStreamsOnlyWhenNoBytesWereDelivered(): void - { - $request = $this->request(Method::GET); - $inner = new QueueAdapter([ - function (callable $sink) use ($request): ResponseInterface { - throw new NetworkException($request, 'reset'); // no bytes emitted - }, - function (callable $sink): ResponseInterface { - $sink('hello'); - - return new Response(200); - }, - ]); - $delays = []; - $received = ''; - - $response = $this->retry($inner, $delays)->stream($request, function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('hello', $received); - $this->assertSame(2, $inner->calls); - } - - public function testItDoesNotRetryStreamsAfterBytesWereDelivered(): void - { - $request = $this->request(Method::GET); - $inner = new QueueAdapter([ - function (callable $sink) use ($request): ResponseInterface { - $sink('partial'); - - throw new NetworkException($request, 'reset'); - }, - ]); - $delays = []; - $received = ''; - - try { - $this->retry($inner, $delays)->stream($request, function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - $this->fail('Expected the failure to be rethrown without retrying.'); - } catch (NetworkException) { - $this->assertSame('partial', $received); - $this->assertSame(1, $inner->calls); - } - } - - public function testItForwardsConfigurationToTheInnerAdapter(): void - { - $inner = new QueueAdapter([]); - $retry = new Retry($inner); - - $configured = $retry - ->withTimeout(5) - ->withConnectTimeout(1) - ->withSslVerification(false) - ->withCustomCA('/etc/ssl/ca.pem') - ->withCertificate('/etc/ssl/client.pem', '/etc/ssl/client.key') - ->withMinTlsVersion(Tls::V1_2); - - $this->assertNotSame($retry, $configured); - $this->assertInstanceOf(Retry::class, $configured); - } - - /** - * @param array $delays - */ - private function retry(Adapter $inner, array &$delays): Retry - { - return new Retry( - $inner, - new Backoff(randomizer: static fn(): float => 1.0), - function (float $seconds) use (&$delays): void { - $delays[] = $seconds; - }, - ); - } - - private function request(string $method): RequestInterface - { - return new Request\Factory()->createRequest($method, 'https://example.com/resource'); - } -} - -final class QueueAdapter implements Adapter -{ - public int $calls = 0; - - /** - * @param array $outcomes - */ - public function __construct(private array $outcomes) {} - - public function withTimeout(float $seconds): static - { - return $this; - } - - public function withConnectTimeout(float $seconds): static - { - return $this; - } - - public function withSslVerification(bool $enabled = true): static - { - return $this; - } - - public function withCustomCA(string $path): static - { - return $this; - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - return $this; - } - - public function withMinTlsVersion(Tls $version): static - { - return $this; - } - - public function withConnectionReuse(bool $enabled = true): static - { - return $this; - } - - public function withFollowRedirects(bool $enabled = true): static - { - return $this; - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->next(static function (string $chunk): void {}); - } - - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - return $this->next($sink); - } - - /** - * @param callable(string): void $sink - */ - private function next(callable $sink): ResponseInterface - { - $outcome = $this->outcomes[$this->calls] ?? throw new LogicException('No more queued outcomes.'); - $this->calls++; - - return $outcome($sink); - } -} diff --git a/packages/client/tests/DecoratorTest.php b/packages/client/tests/DecoratorTest.php deleted file mode 100644 index 34f91fd45..000000000 --- a/packages/client/tests/DecoratorTest.php +++ /dev/null @@ -1,116 +0,0 @@ -assertSame(200, $decorator->sendRequest($this->request())->getStatusCode()); - } - - public function testItDelegatesStreamRequestToTheInnerAdapter(): void - { - $decorator = new PassthroughDecorator(new SwappableAdapter(200)); - $received = ''; - - $response = $decorator->stream($this->request(), function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - $this->assertSame('chunk', $received); - $this->assertSame(200, $response->getStatusCode()); - } - - public function testItForwardsConfigurationToAReconfiguredInnerClone(): void - { - $decorator = new PassthroughDecorator(new SwappableAdapter(200)); - $configured = $decorator->withTimeout(5); - - $this->assertNotSame($decorator, $configured); - $this->assertInstanceOf(PassthroughDecorator::class, $configured); - $this->assertSame(200, $decorator->sendRequest($this->request())->getStatusCode()); - $this->assertSame(299, $configured->sendRequest($this->request())->getStatusCode()); - } - - private function request(): RequestInterface - { - return new Request\Factory()->createRequest(Method::GET, 'https://example.com'); - } -} - -final class PassthroughDecorator extends Decorator {} - -final class SwappableAdapter implements Adapter -{ - public function __construct(private int $status = 200) {} - - public function withTimeout(float $seconds): static - { - $clone = clone $this; - $clone->status = 299; - - return $clone; - } - - public function withConnectTimeout(float $seconds): static - { - return $this; - } - - public function withSslVerification(bool $enabled = true): static - { - return $this; - } - - public function withCustomCA(string $path): static - { - return $this; - } - - public function withCertificate(string $certPath, string $keyPath, ?string $passphrase = null): static - { - return $this; - } - - public function withMinTlsVersion(Tls $version): static - { - return $this; - } - - public function withConnectionReuse(bool $enabled = true): static - { - return $this; - } - - public function withFollowRedirects(bool $enabled = true): static - { - return $this; - } - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return new Response($this->status); - } - - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - $sink('chunk'); - - return new Response($this->status); - } -} diff --git a/packages/client/tests/ExceptionTest.php b/packages/client/tests/ExceptionTest.php deleted file mode 100644 index 2f9271b32..000000000 --- a/packages/client/tests/ExceptionTest.php +++ /dev/null @@ -1,44 +0,0 @@ -assertContains(RequestExceptionInterface::class, class_implements(RequestException::class)); - $this->assertContains(RequestExceptionInterface::class, class_implements(AdapterInitializationException::class)); - $this->assertContains(RequestExceptionInterface::class, class_implements(AdapterPreconditionException::class)); - $this->assertContains(RequestExceptionInterface::class, class_implements(InvalidResponseException::class)); - $this->assertContains(RequestExceptionInterface::class, class_implements(InvalidUriException::class)); - } - - public function testNetworkExceptionsRemainPsrNetworkExceptions(): void - { - $this->assertContains(NetworkExceptionInterface::class, class_implements(ConnectionException::class)); - $this->assertContains(NetworkExceptionInterface::class, class_implements(DnsException::class)); - $this->assertContains(NetworkExceptionInterface::class, class_implements(NetworkException::class)); - $this->assertContains(NetworkExceptionInterface::class, class_implements(ProtocolException::class)); - $this->assertContains(NetworkExceptionInterface::class, class_implements(ProxyException::class)); - $this->assertContains(NetworkExceptionInterface::class, class_implements(TlsException::class)); - $this->assertContains(NetworkExceptionInterface::class, class_implements(TimeoutException::class)); - } -} diff --git a/packages/client/tests/OldNamesConsumer.php b/packages/client/tests/OldNamesConsumer.php deleted file mode 100644 index 501ae35f2..000000000 --- a/packages/client/tests/OldNamesConsumer.php +++ /dev/null @@ -1,19 +0,0 @@ -stream = $client; - } -} diff --git a/packages/client/tests/PoolTest.php b/packages/client/tests/PoolTest.php deleted file mode 100644 index 01958b866..000000000 --- a/packages/client/tests/PoolTest.php +++ /dev/null @@ -1,91 +0,0 @@ -connections(fn(): \Utopia\Client\Tests\FakeClient => new FakeClient(200))); - - $this->assertSame(200, $pool->sendRequest($this->request())->getStatusCode()); - } - - public function testItBorrowsAConnectionToStreamARequest(): void - { - $pool = new Pool($this->connections(fn(): \Utopia\Client\Tests\FakeClient => new FakeClient(200))); - $received = ''; - - $response = $pool->stream($this->request(), function (string $chunk) use (&$received): void { - $received .= $chunk; - }); - - $this->assertSame('chunk', $received); - $this->assertSame(200, $response->getStatusCode()); - } - - public function testItReclaimsTheConnectionSoItCanBeReused(): void - { - $created = 0; - $pool = new Pool($this->connections(function () use (&$created): FakeClient { - $created++; - - return new FakeClient(200); - }, size: 1)); - - $pool->sendRequest($this->request()); - $pool->sendRequest($this->request()); - - $this->assertSame(1, $created); - } - - /** - * Mirrors how a caller builds a pool: a factory returning a concrete client - * type flows through to `new Pool()` without needing the intersection type. - * - * @template T of \Psr\Http\Client\ClientInterface&StreamingClientInterface - * - * @param \Closure(): T $init - * - * @return Connections - */ - private function connections(\Closure $init, int $size = 4): Connections - { - return new Connections(new Stack(), 'test', $size, $init, timeout: 0.0); - } - - private function request(): RequestInterface - { - return new Request\Factory()->createRequest(Method::GET, 'https://example.com'); - } -} - -final readonly class FakeClient implements \Psr\Http\Client\ClientInterface, StreamingClientInterface -{ - public function __construct(private int $status) {} - - public function sendRequest(RequestInterface $request): ResponseInterface - { - return new Response($this->status); - } - - public function stream(RequestInterface $request, callable $sink): ResponseInterface - { - $sink('chunk'); - - return new Response($this->status); - } -} diff --git a/packages/client/tests/RedirectTest.php b/packages/client/tests/RedirectTest.php deleted file mode 100644 index 48976c33f..000000000 --- a/packages/client/tests/RedirectTest.php +++ /dev/null @@ -1,88 +0,0 @@ -assertSame('http://example.test:8080/a/final', (string) Redirect::resolve($base, '../final')); - $this->assertSame('http://example.test:8080/a/b/final', (string) Redirect::resolve($base, './final')); - $this->assertSame('http://example.test:8080/a/b/final', (string) Redirect::resolve($base, 'final')); - $this->assertSame('http://example.test:8080/final', (string) Redirect::resolve($base, '/final')); - $this->assertSame('http://example.test:8080/final', (string) Redirect::resolve($base, '../../final')); - } - - public function testItPreservesRfc3986PathAndQuerySemantics(): void - { - $base = Uri::parse('http://example.test/a/b/c?old=1'); - - $this->assertSame('http://example.test/a/b//final', (string) Redirect::resolve($base, '../b//final')); - $this->assertSame('http://example.test/a/b/', (string) Redirect::resolve($base, './')); - $this->assertSame('http://example.test/a/b/c', (string) Redirect::resolve($base, '?')); - $this->assertSame('http://example.test/a/b/c?new=1', (string) Redirect::resolve($base, '?new=1')); - $this->assertSame('http://example.test/a/b/c?old=1#result', (string) Redirect::resolve($base, '#result')); - } - - public function testItResolvesAbsoluteHttpAndHttpsLocations(): void - { - $base = Uri::parse('http://example.test/a/b'); - - $this->assertSame('http://other.test/x', (string) Redirect::resolve($base, 'http://other.test/x')); - $this->assertSame('https://other.test/x', (string) Redirect::resolve($base, 'https://other.test/x')); - $this->assertSame('http://cdn.test/img', (string) Redirect::resolve($base, '//cdn.test/img')); - } - - public function testItKeepsSensitiveHeadersOnSameOriginRedirects(): void - { - $from = Uri::parse('http://example.test:8080/start'); - $to = Redirect::resolve($from, '/final'); - - $this->assertFalse(Redirect::shouldStripSensitiveHeaders($from, $to)); - $this->assertTrue(Redirect::isSameOrigin($from, $to)); - } - - public function testItStripsSensitiveHeadersWhenTheHostChanges(): void - { - $from = Uri::parse('http://example.test/start'); - $to = Redirect::resolve($from, 'http://other.test/final'); - - $this->assertTrue(Redirect::shouldStripSensitiveHeaders($from, $to)); - - $request = new Request\Factory() - ->createRequest(Method::GET, (string) $from) - ->withHeader(Header::AUTHORIZATION, 'Bearer secret') - ->withHeader(Header::COOKIE, 'session=1') - ->withHeader('Cookie2', 'legacy=1') - ->withHeader('Proxy-Authorization', 'Basic abc') - ->withHeader('X-Trace', 'keep'); - - $stripped = Redirect::withoutSensitiveHeaders($request); - - $this->assertSame('', $stripped->getHeaderLine(Header::AUTHORIZATION)); - $this->assertSame('', $stripped->getHeaderLine(Header::COOKIE)); - $this->assertSame('', $stripped->getHeaderLine('Cookie2')); - $this->assertSame('', $stripped->getHeaderLine('Proxy-Authorization')); - $this->assertSame('keep', $stripped->getHeaderLine('X-Trace')); - } - - public function testItStripsSensitiveHeadersOnHttpsToHttpEvenOnTheSameHost(): void - { - $from = Uri::parse('https://example.test/start'); - $to = Redirect::resolve($from, 'http://example.test/final'); - - $this->assertTrue(Redirect::shouldStripSensitiveHeaders($from, $to)); - $this->assertFalse(Redirect::isSameOrigin($from, $to)); - } -} diff --git a/packages/client/tests/Server/Http.php b/packages/client/tests/Server/Http.php deleted file mode 100644 index c5393922a..000000000 --- a/packages/client/tests/Server/Http.php +++ /dev/null @@ -1,352 +0,0 @@ -|null $receivedRequests receives the HTTP request headers observed by the server - * @param-out list $receivedRequests - * - * @return int connections the server accepted - */ - public static function dropsFirstKeepAliveConnection(callable $test, bool $tls = false, bool $dropResponse = false, string $peerName = '127.0.0.1', ?array &$receivedRequests = null): int - { - $readyFile = tempnam(sys_get_temp_dir(), 'utopia-drop-ready-'); - $countFile = tempnam(sys_get_temp_dir(), 'utopia-drop-count-'); - - if ($readyFile === false || $countFile === false) { - throw new RuntimeException('Unable to create drop server temp files.'); - } - - unlink($readyFile); - - $certificateFile = ''; - if ($tls) { - $certificateFile = tempnam(sys_get_temp_dir(), 'utopia-tls-'); - $key = openssl_pkey_new(['private_key_bits' => 2048]); - if ($certificateFile === false || $key === false) { - throw new RuntimeException('Unable to create the TLS fixture key.'); - } - - $csr = openssl_csr_new(['commonName' => $peerName], $key); - if (!$csr instanceof \OpenSSLCertificateSigningRequest || !$key instanceof \OpenSSLAsymmetricKey) { - throw new RuntimeException('Unable to create the TLS fixture CSR.'); - } - - $certificate = openssl_csr_sign($csr, null, $key, 1); - if ($certificate === false) { - throw new RuntimeException('Unable to sign the TLS fixture certificate.'); - } - - if (!openssl_x509_export($certificate, $pem) || !openssl_pkey_export($key, $privateKey) - || !\is_string($pem) || !\is_string($privateKey)) { - throw new RuntimeException('Unable to export the TLS fixture certificate.'); - } - - file_put_contents($certificateFile, $pem . $privateKey); - } - - $port = self::availablePort(); - - $code = <<<'PHP' - $port = (int) $argv[1]; - $readyFile = $argv[2]; - $countFile = $argv[3]; - $certificateFile = $argv[4]; - $tls = $certificateFile !== ''; - $dropResponse = $argv[5] === '1'; - $requests = 0; - $context = stream_context_create(['ssl' => ['local_cert' => $certificateFile, 'verify_peer' => false]]); - $server = stream_socket_server(($tls ? 'tls' : 'tcp') . '://127.0.0.1:' . $port, $errorCode, $errorMessage, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context); - if (!is_resource($server)) { - fwrite(STDERR, $errorCode . ' ' . $errorMessage); - exit(1); - } - file_put_contents($readyFile, 'ready'); - $connections = 0; - $receivedRequests = []; - while (true) { - $connection = @stream_socket_accept($server, 30); - if (!is_resource($connection)) { - continue; - } - $connections++; - file_put_contents($countFile, json_encode(['connections' => $connections, 'requests' => $receivedRequests])); - $dropAfterResponse = ($connections === 1); - while (true) { - $request = ''; - while (($line = fgets($connection, 8192)) !== false) { - $request .= $line; - if ($line === "\r\n" || $line === "\n") { - break; - } - } - if ($request === '') { - break; - } - $length = preg_match('/Content-Length:\s*(\d+)/i', $request, $match) ? (int) $match[1] : 0; - $body = ''; - while (strlen($body) < $length) { - $chunk = fread($connection, $length - strlen($body)); - if ($chunk === false || $chunk === '') { - break; - } - $body .= $chunk; - } - $requests++; - $receivedRequests[] = $request; - file_put_contents($countFile, json_encode(['connections' => $connections, 'requests' => $receivedRequests])); - if ($dropAfterResponse && $dropResponse && $requests === 2) { - stream_socket_shutdown($connection, STREAM_SHUT_RDWR); - break; - } - $body = $body === '' ? 'ok' : $body; - @fwrite($connection, "HTTP/1.1 200 OK\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body); - if ($dropAfterResponse && !$dropResponse) { - // Close TCP without TLS close_notify, as an idle peer can disappear. - stream_socket_shutdown($connection, STREAM_SHUT_RDWR); - break; - } - } - @fclose($connection); - } - PHP; - - $server = proc_open( - [\PHP_BINARY, '-r', $code, (string) $port, $readyFile, $countFile, $certificateFile, $dropResponse ? '1' : '0'], - [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - ); - - if (!\is_resource($server)) { - throw new RuntimeException('Unable to start the drop test server.'); - } - - unset($pipes); - self::waitForReadyFile($readyFile); - - try { - $test($port, $certificateFile); - } finally { - self::stop($server); - /** @var array{connections: int, requests: list} $observed */ - $observed = json_decode((string) file_get_contents($countFile), true) ?: ['connections' => 0, 'requests' => []]; - $count = $observed['connections']; - $receivedRequests = $observed['requests']; - @unlink($countFile); - if ($certificateFile !== '') { - @unlink($certificateFile); - } - } - - return $count; - } - - /** - * Run the callable against a port with nothing listening on it, so a - * connection attempt is refused. No server is started or torn down. - * - * @param callable(int): void $test receives the unbound port - */ - public static function unbound(callable $test): void - { - $test(self::availablePort()); - } - - private static function availablePort(): int - { - $server = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); - - if (!\is_resource($server)) { - throw new RuntimeException('Unable to find an available TCP port: ' . $errorCode . ' ' . $errorMessage); - } - - $name = stream_socket_get_name($server, false); - - fclose($server); - - if ($name === false) { - throw new RuntimeException('Unable to read TCP port.'); - } - - $port = parse_url('tcp://' . $name, PHP_URL_PORT); - - if (!\is_int($port)) { - throw new RuntimeException('Unable to parse TCP port.'); - } - - return $port; - } - - /** - * @return resource - */ - private static function start(int $port): mixed - { - $server = proc_open( - [\PHP_BINARY, '-d', 'post_max_size=64M', '-d', 'upload_max_filesize=64M', '-d', 'memory_limit=256M', '-S', '127.0.0.1:' . $port, \dirname(__DIR__) . '/server.php'], - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - ); - - if (!\is_resource($server)) { - throw new RuntimeException('Unable to start PHP test server.'); - } - - unset($pipes); - self::waitForPort($port); - - return $server; - } - - /** - * @return resource - */ - private static function startRaw(int $port, string $response): mixed - { - $readyFile = tempnam(sys_get_temp_dir(), 'utopia-raw-server-'); - - if ($readyFile === false) { - throw new RuntimeException('Unable to create raw response server readiness file.'); - } - - unlink($readyFile); - - $code = <<<'PHP' -$port = (int) $argv[1]; -$response = base64_decode($argv[2]); -$readyFile = $argv[3]; -$server = stream_socket_server('tcp://127.0.0.1:' . $port, $errorCode, $errorMessage); -if (!is_resource($server)) { - fwrite(STDERR, $errorCode . ' ' . $errorMessage); - exit(1); -} -file_put_contents($readyFile, 'ready'); -$connection = @stream_socket_accept($server, 10); -if (is_resource($connection)) { - fread($connection, 8192); - fwrite($connection, $response); - fclose($connection); -} -fclose($server); -PHP; - - $server = proc_open( - [\PHP_BINARY, '-r', $code, (string) $port, base64_encode($response), $readyFile], - [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], - $pipes, - ); - - if (!\is_resource($server)) { - throw new RuntimeException('Unable to start raw response test server.'); - } - - unset($pipes); - self::waitForReadyFile($readyFile); - - return $server; - } - - /** - * @param resource $server - */ - private static function stop(mixed $server): void - { - proc_terminate($server); - proc_close($server); - } - - private static function waitForPort(int $port): void - { - $deadline = microtime(true) + 5; - - do { - $connection = @fsockopen('127.0.0.1', $port); - - if (\is_resource($connection)) { - fclose($connection); - - return; - } - - usleep(50_000); - } while (microtime(true) < $deadline); - - throw new RuntimeException('PHP test server did not start.'); - } - - private static function waitForReadyFile(string $readyFile): void - { - $deadline = microtime(true) + 5; - - do { - if (is_file($readyFile)) { - unlink($readyFile); - - return; - } - - usleep(50_000); - } while (microtime(true) < $deadline); - - throw new RuntimeException('Raw response test server did not start.'); - } -} diff --git a/packages/client/tests/server.php b/packages/client/tests/server.php deleted file mode 100644 index 7954e7f24..000000000 --- a/packages/client/tests/server.php +++ /dev/null @@ -1,408 +0,0 @@ - $value) { - if (!is_string($name)) { - continue; - } - - if (!is_string($value)) { - continue; - } - - if (strcasecmp($name, 'Authorization') === 0) { - $authorization = $value; - } - - if (strcasecmp($name, 'Cookie') === 0) { - $cookie = $value; - } - } - - $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? $authorization; - $authorization = is_string($authorization) ? $authorization : ''; - $cookie = is_string($_SERVER['HTTP_COOKIE'] ?? null) ? $_SERVER['HTTP_COOKIE'] : $cookie; - - echo $authorization . '|' . $cookie; - - return; -} - -if (preg_match('#^/hops/(\d+)$#', $path, $matches) === 1) { - $remaining = (int) $matches[1]; - - if ($remaining === 0) { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - echo 'hopped'; - - return; - } - - http_response_code(302); - header('Location: /hops/' . ($remaining - 1)); - header('Content-Type: text/plain;charset=UTF-8'); - echo 'redirect'; - - return; -} - -if ($path === '/headers') { - http_response_code(204); - header('X-Trace: one', false); - header('X-Trace: two', false); - header('x-Mixed-Case: Value'); - - return; -} - -if ($path === '/binary') { - http_response_code(200); - header('Content-Type: application/octet-stream'); - echo "\x00\x01hello\xff"; - - return; -} - -if ($path === '/request-headers') { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - $host = $_SERVER['HTTP_HOST'] ?? ''; - $host = is_string($host) ? $host : ''; - $trace = $_SERVER['HTTP_X_TRACE'] ?? ''; - $trace = is_string($trace) ? $trace : ''; - - echo $host . ':' . $trace; - - return; -} - -if ($path === '/request-target') { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo is_string($requestUri) ? $requestUri : ''; - - return; -} - -if ($path === '/space%20name') { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo is_string($requestUri) ? $requestUri : ''; - - return; -} - -if ($path === '/' && is_string($requestUri) && str_contains($requestUri, 'ping=1')) { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo $requestUri; - - return; -} - -if ($path === '/method') { - $requestMethod = $_SERVER['REQUEST_METHOD'] ?? ''; - $method = is_string($requestMethod) ? $requestMethod : ''; - - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - header('X-Request-Method: ' . $method); - - echo $method; - - return; -} - -if ($path === '/body-info') { - $body = file_get_contents('php://input'); - $body = $body === false ? '' : $body; - - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo strlen($body) . ':' . hash('sha256', $body); - - return; -} - -if ($path === '/multipart') { - $name = $_POST['name'] ?? ''; - $name = is_string($name) ? $name : ''; - - $file = $_FILES['file'] ?? null; - $size = is_array($file) && isset($file['size']) && is_int($file['size']) ? $file['size'] : 0; - $tmp = is_array($file) && isset($file['tmp_name']) && is_string($file['tmp_name']) ? $file['tmp_name'] : ''; - $hash = $tmp !== '' && is_file($tmp) ? hash_file('sha256', $tmp) : ''; - - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo $name . ':' . $size . ':' . $hash; - - return; -} - -if ($path === '/selected-headers') { - $comma = $_SERVER['HTTP_X_COMMA'] ?? ''; - $comma = is_string($comma) ? $comma : ''; - $zero = $_SERVER['HTTP_X_ZERO'] ?? ''; - $zero = is_string($zero) ? $zero : ''; - $mixed = $_SERVER['HTTP_X_MIXED_REQUEST'] ?? ''; - $mixed = is_string($mixed) ? $mixed : ''; - - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo $comma . ':' . $zero . ':' . $mixed; - - return; -} - -if ($path === '/large-response') { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - echo str_repeat('abcd', 65_536); - - return; -} - -if ($path === '/stream') { - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - - while (ob_get_level() > 0) { - ob_end_flush(); - } - - for ($i = 0; $i < 5; $i++) { - echo 'chunk' . $i . "\n"; - flush(); - usleep(20_000); - } - - return; -} - -if ($path === '/stream-large') { - $chunkSize = 65_536; - $chunkCount = 128; - - http_response_code(200); - header('Content-Type: application/octet-stream'); - header('Content-Length: ' . ($chunkSize * $chunkCount)); - - $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; - $method = is_string($requestMethod) ? $requestMethod : 'GET'; - - if (strtoupper($method) === 'HEAD') { - return; - } - - while (ob_get_level() > 0) { - ob_end_flush(); - } - - $chunk = str_repeat('a', $chunkSize); - - for ($i = 0; $i < $chunkCount; $i++) { - echo $chunk; - flush(); - } - - return; -} - -if ($path === '/slow') { - sleep(1); - http_response_code(200); - header('Content-Type: text/plain;charset=UTF-8'); - echo 'slow'; - - return; -} - -if ($path === '/gzip') { - $accept = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? ''; - $accept = is_string($accept) ? $accept : ''; - - // ?repeat sizes the payload, ?type=binary returns all 256 byte values, - // ?compress=0 forces plaintext even when gzip was offered. - $repeat = isset($_GET['repeat']) && is_numeric($_GET['repeat']) ? max(1, (int) $_GET['repeat']) : 64; - $binary = ($_GET['type'] ?? '') === 'binary'; - $compressible = ($_GET['compress'] ?? '1') !== '0'; - - if ($binary) { - $unit = ''; - for ($byte = 0; $byte < 256; $byte++) { - $unit .= chr($byte); - } - - $payload = str_repeat($unit, $repeat); - } else { - $payload = str_repeat('utopia ', $repeat); - } - - http_response_code(200); - header('Content-Type: ' . ($binary ? 'application/octet-stream' : 'text/plain;charset=UTF-8')); - // Echo what the client advertised so a test can assert negotiation. - header('X-Accept-Encoding: ' . $accept); - - if ($compressible && str_contains($accept, 'gzip')) { - header('Content-Encoding: gzip'); - echo gzencode($payload); - - return; - } - - header('Content-Length: ' . strlen($payload)); - echo $payload; - - return; -} - -http_response_code(202); -header('Content-Type: text/plain;charset=UTF-8'); - -$requestMethod = $_SERVER['REQUEST_METHOD'] ?? ''; -$method = is_string($requestMethod) ? $requestMethod : ''; -$customHeader = $_SERVER['HTTP_X_CUSTOM'] ?? ''; -$customHeader = is_string($customHeader) ? $customHeader : ''; -$body = file_get_contents('php://input'); - -echo $method . ':' . $path . ':' . $customHeader . ':' . ($body === false ? '' : $body); diff --git a/packages/span/.github/workflows/mirror.yml b/packages/span/.github/workflows/mirror.yml deleted file mode 100644 index 0efc7dd7e..000000000 --- a/packages/span/.github/workflows/mirror.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Mirror - -on: - pull_request_target: - types: [opened] - issues: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - redirect: - uses: utopia-php/monorepo/.github/workflows/mirror-redirect.yml@main - with: - package: span diff --git a/packages/span/.gitignore b/packages/span/.gitignore deleted file mode 100644 index d8fb0474b..000000000 --- a/packages/span/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/vendor/ -composer.lock -.phpunit.cache/ -.phpunit.result.cache diff --git a/packages/span/CHANGELOG.md b/packages/span/CHANGELOG.md deleted file mode 100644 index de0cd5bf3..000000000 --- a/packages/span/CHANGELOG.md +++ /dev/null @@ -1,30 +0,0 @@ -# Changelog - -## Unreleased - -- Sentry exporter: deliver envelopes exclusively through PSR-18. It defaults to `utopia-php/client` with its cURL adapter and accepts injected clients such as a coroutine-safe `Utopia\Client\Pool`. - -## 3.0.0 - -### Breaking changes - -- Removed `Span::addExporter()`. Use `Span::setExporters(Exporter ...$exporters)` to register exporters; calls replace the full set. -- Removed `Span::resetExporters()`. Call `Span::setExporters()` with no arguments to clear. -- Removed `Span::resetStorage()` and `Span::reset()`. `Span::setStorage()` now accepts `?Storage` — pass `null` to clear. -- `Exporter` interface gained a `sample(Span $span): bool` method. Exporters decide per-span whether `export()` is called; the per-registration sampler closure is gone. - -### Exporter behaviour - -- Built-in exporters now take an optional `sampler` closure as their first constructor argument by convention. -- `Stdout` and `Pretty` default to exporting every span. -- `Sentry` is hard-wired to error spans only. A user-supplied `sampler` is composed (AND) with the error filter, so it can further restrict but not broaden what is sent. -- `None` always returns `false` from `sample()`. - -### Other - -- `Span` now declares `strict_types=1`. -- `Span::finish()` accepts the triggering error directly: `finish(?string $level = null, ?Throwable $error = null)`. The level override is also passed through `finish()` rather than set beforehand. -- Added `Pretty` exporter for colourful, human-readable local development output. -- Added automatic `level` attribute on spans (`error` when an error is captured, `info` otherwise; overridable via `finish(level: ...)`). -- Sentry exporter: added `release`, `server_name`, SDK and runtime metadata; configurable attribute classifier (tag/context/extra); fixes for dropped HTTP attributes and empty extras. -- Dropped PHP 8.1 support. diff --git a/packages/span/README.md b/packages/span/README.md deleted file mode 100644 index 5f5b3495a..000000000 --- a/packages/span/README.md +++ /dev/null @@ -1,288 +0,0 @@ -# Utopia Span - -> [!IMPORTANT] -> This repository is a read-only mirror of the [utopia-php monorepo](https://github.com/utopia-php/monorepo). Development happens in [`packages/span`](https://github.com/utopia-php/monorepo/tree/main/packages/span) — please open issues and pull requests there. - -A simple, memory-safe span tracing library for PHP with Swoole coroutine support. - -## Installation - -```bash -composer require utopia-php/span -``` - -## Quick start - -```php -use Utopia\Span\Span; -use Utopia\Span\Storage; -use Utopia\Span\Exporter; - -// Bootstrap once at startup -Span::setStorage(new Storage\Auto()); -Span::setExporters(new Exporter\Stdout()); - -// Create a span -$span = Span::init('http.request'); -$span->set('user.id', '123'); -$span->finish(); -``` - -## Usage - -### Setting attributes - -Everything is a flat key-value attribute. Only scalar types are allowed (string, int, float, bool, null): - -```php -$span = Span::init('api.request'); -$span->set('service.name', 'api'); -$span->set('request.duration_ms', 42.5); -$span->set('request.cached', true); -$span->finish(); -``` - -### Built-in attributes - -Spans automatically include these attributes: - -| Attribute | Description | -| ------------------ | ----------------------------------- | -| `span.trace_id` | Unique trace identifier (32 hex chars) | -| `span.id` | Unique span identifier (16 hex chars) | -| `span.started_at` | Start timestamp in seconds (float) | -| `span.finished_at` | End timestamp in seconds (float) | -| `span.duration` | Duration in seconds (float) | -| `level` | `error` if error set, `info` otherwise | - -### Static helpers - -Use static methods anywhere in your codebase without passing the span around: - -```php -// Set attribute on current span -Span::add('db.query_count', 5); - -// Add warning details as attributes -Span::add('warning', 'retry scheduled'); -``` - -### Error handling - -Pass the exception to `finish()` when the span ends because of an error: - -```php -$span = Span::init('api.request'); - -try { - // ... -} catch (Throwable $e) { - $span->finish(error: $e); - throw $e; -} -``` - -Exporters access the exception via `$span->getError()` and extract what they need (message, trace, etc.). - -Use `setError()` when you need to record the error before the span ends, such as before cleanup work that should still be included in the same span. - -The `level` attribute is set when the span finishes. It defaults to `Level::Error` when an error is captured and `Level::Info` otherwise. Pass a `Level` to `finish()` to override it: - -```php -use Utopia\Span\Level; - -$span->finish(level: Level::Warn, error: $e); -``` - -Level names follow Grafana Loki's `detected_level` vocabulary (note `warn`). The Sentry exporter only sends spans at `Level::Warn` or above — translating to Sentry's own terms (`warn` → `warning`) — and reports each at its own level; lower levels (`Info`, `Debug`) are skipped. - -Use attributes for warning details that do not end the span: - -```php -Span::add('warning', 'retry scheduled'); -Span::add('warning.message', $message); -``` - -### Distributed tracing - -Propagate trace context across services using W3C Trace Context headers: - -```php -// Service A: outgoing request -$client->post('/api/downstream', $payload, [ - 'traceparent' => Span::traceparent(), -]); - -// Service B: incoming request -$span = Span::init('http.request', $request->getHeader('traceparent')); -``` - -### Sampling - -Each exporter decides which spans it accepts via its `sample()` method. Most built-in exporters accept a `sampler` closure as their first constructor argument: - -```php -Span::setExporters( - new Exporter\Stdout( - sampler: fn(Span $s) => - $s->getError() !== null || // errors - $s->get('span.duration') > 5.0 || // slow requests (>5s) - $s->get('plan') === 'enterprise' // enterprise customers - ), -); -``` - -The Sentry exporter is hard-wired to error spans only; a custom sampler is composed with that filter and can further restrict — but not broaden — what is sent. - -## Storage backends - -| Backend | Use Case | -| ------------------- | --------------------------------------- | -| `Storage\Auto` | Auto-detects best storage (recommended) | -| `Storage\Memory` | Plain PHP (FPM, CLI) | -| `Storage\Coroutine` | Swoole coroutine contexts | - -## Exporters - -| Exporter | Description | -| ------------------ | ------------------------------------ | -| `Exporter\Stdout` | JSON to stdout/stderr | -| `Exporter\Pretty` | Colourful human-readable output | -| `Exporter\Sentry` | Sentry events (Issues) | -| `Exporter\None` | Discard (for testing) | - -### Stdout exporter - -```php -Span::setExporters(new Exporter\Stdout( - maxTraceFrames: 3 // default, limits error stacktrace length -)); -``` - -Outputs JSON to stdout (info) or stderr (errors). Exports every span by default; pass `sampler:` to filter. - -### Pretty exporter - -```php -Span::setExporters(new Exporter\Pretty( - maxTraceFrames: 3, // default, limits error stacktrace length - width: 60 // default, separator line width -)); -``` - -Colourful, multi-line output for local development. Attributes are displayed with aligned values, duration is colour-coded (green < 100ms, yellow < 1s, red >= 1s), and errors are highlighted in red. Writes to stdout (info) or stderr (errors). - -``` -http.request · 12.3ms · abc12345 - - http.method GET - http.url /api/users - user.id 42 - -──────────────────────────────────────────────────────────── -``` - -### Sentry exporter - -```php -Span::setExporters(new Exporter\Sentry( - dsn: 'https://key@sentry.io/123', - environment: 'production' // optional -)); -``` - -Only exports error spans with full stacktraces. Non-error spans are skipped, even if you pass a custom `sampler`. - -Pass any PSR-18 client as `client` to control the HTTP transport. Workers can -inject a coroutine-safe `Utopia\Client\Pool`. When omitted, the exporter creates -a `Utopia\Client` using its cURL adapter. - -```php -Span::setExporters(new Exporter\Sentry( - dsn: 'https://key@sentry.io/123', - client: $clientPool, -)); -``` - -### Custom exporter - -```php -use Utopia\Span\Exporter\Exporter; -use Utopia\Span\Span; - -class MyExporter implements Exporter -{ - public function sample(Span $span): bool - { - return true; // export every span - } - - public function export(Span $span): void - { - $data = $span->getAttributes(); - $error = $span->getError(); - // Send to your backend - } -} -``` - -## Testing - -Disable or capture spans in tests: - -```php -// Option 1: Discard all spans -Span::setExporters(new Exporter\None()); - -// Option 2: Capture for assertions -$spans = []; -Span::setExporters(new class($spans) implements Exporter { - public function __construct(private array &$spans) {} - public function sample(Span $span): bool { return true; } - public function export(Span $span): void { - $this->spans[] = $span; - } -}); - -// Run code... - -$this->assertCount(1, $spans); -$this->assertEquals('http.request', $spans[0]->get('action')); -``` - -## API reference - -### Span (static) - -| Method | Description | -| ---------------------------------------------------- | ------------------------------------- | -| `setStorage(?Storage $storage)` | Set the storage backend (null clears) | -| `setExporters(Exporter ...$exporters)` | Replace all exporters | -| `init(string $action, ?string $traceparent): Span` | Create and store a new span | -| `current(): ?Span` | Get the current span | -| `add(string $key, scalar $value)` | Set attribute on current span | -| `traceparent(): ?string` | Get traceparent header from current span | - -### Span (instance) - -| Method | Description | -| --------------------------------------- | ---------------------------------- | -| `set(string $key, scalar $value): self` | Set an attribute | -| `get(string $key): scalar` | Get an attribute | -| `getAttributes(): array` | Get all attributes | -| `getAction(): string` | Get the span action | -| `setError(Throwable $e): self` | Capture exception | -| `getError(): ?Throwable` | Get captured exception | -| `getTraceparent(): string` | Get W3C traceparent header value | -| `finish(?Level $level = null, ?Throwable $error = null): void` | End span and export | - -### Attribute conventions - -| Prefix | Description | -| -------- | ---------------------- | -| `span.*` | Built-in span metadata | -| `*` | User-defined | - -## License - -MIT diff --git a/packages/span/composer.json b/packages/span/composer.json deleted file mode 100644 index 8a76c1f8e..000000000 --- a/packages/span/composer.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "utopia-php/span", - "description": "Simple span tracing library for PHP with coroutine support", - "type": "library", - "license": "MIT", - "autoload": { - "psr-4": { - "Utopia\\Span\\": "src/Span/" - } - }, - "autoload-dev": { - "psr-4": { - "Utopia\\Span\\Tests\\": "tests/" - } - }, - "require": { - "php": ">=8.4", - "psr/http-client": "^1.0", - "utopia-php/client": "^0.2 || ^0.3 || ^0.4", - "utopia-php/psr7": "^0.1 || ^0.2" - }, - "require-dev": { - "swoole/ide-helper": "^5.0" - }, - "scripts": { - "test": "phpunit" - }, - "suggest": { - "ext-swoole": "Required for coroutine-based storage" - } -} diff --git a/packages/span/phpstan.neon b/packages/span/phpstan.neon deleted file mode 100644 index 9cd8f3b93..000000000 --- a/packages/span/phpstan.neon +++ /dev/null @@ -1,6 +0,0 @@ -parameters: - level: max - paths: - - src - scanDirectories: - - vendor/swoole/ide-helper diff --git a/packages/span/phpunit.xml b/packages/span/phpunit.xml deleted file mode 100644 index 6f244f569..000000000 --- a/packages/span/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - tests - - - - - src - - - diff --git a/packages/span/rector.php b/packages/span/rector.php deleted file mode 100644 index 6f7b98dc2..000000000 --- a/packages/span/rector.php +++ /dev/null @@ -1,18 +0,0 @@ -withPaths([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->withPhpSets(php84: true) - ->withPreparedSets( - deadCode: true, - codeQuality: true, - typeDeclarations: true, - earlyReturn: true, - ); diff --git a/packages/span/src/Span/Exporter/Exporter.php b/packages/span/src/Span/Exporter/Exporter.php deleted file mode 100644 index 0146cb268..000000000 --- a/packages/span/src/Span/Exporter/Exporter.php +++ /dev/null @@ -1,34 +0,0 @@ -getAttributes() for metadata and $span->getError() for exceptions. - * - * @param Span $span The finished span to export - */ - public function export(Span $span): void; - - /** - * Decide whether a span should be exported. - * - * Return false to drop the span. Implementations that always export should return true. - * - * @param Span $span The finished span to consider - */ - public function sample(Span $span): bool; -} diff --git a/packages/span/src/Span/Exporter/None.php b/packages/span/src/Span/Exporter/None.php deleted file mode 100644 index d64390584..000000000 --- a/packages/span/src/Span/Exporter/None.php +++ /dev/null @@ -1,24 +0,0 @@ -sampler = $sampler ?? static fn(Span $span): bool => true; - } - - public function sample(Span $span): bool - { - return ($this->sampler)($span); - } - - public function export(Span $span): void - { - $error = $span->getError(); - $hasError = $error instanceof \Throwable; - $stream = $hasError ? STDERR : STDOUT; - - $lines = []; - - $lines[] = $this->header($span, $hasError); - $lines[] = ''; - - $attributes = []; - foreach ($span->getAttributes() as $key => $value) { - if (!str_starts_with($key, 'span.')) { - $attributes[$key] = $value; - } - } - - if (\array_key_exists('level', $attributes)) { - $level = $attributes['level']; - unset($attributes['level']); - $attributes = ['level' => $level] + $attributes; - } - - $maxKeyLen = 0; - foreach (array_keys($attributes) as $key) { - $maxKeyLen = max($maxKeyLen, \strlen($key)); - } - - foreach ($attributes as $key => $value) { - $lines[] = $this->attribute($key, $value, $maxKeyLen); - } - - if ($hasError) { - if (\count($attributes) > 0) { - $lines[] = ''; - } - - $lines[] = $this->error($error); - - $trace = $error->getTrace(); - $limited = \array_slice($trace, 0, $this->maxTraceFrames); - - foreach ($limited as $frame) { - $file = $frame['file'] ?? 'unknown'; - $line = $frame['line'] ?? '?'; - $lines[] = self::DIM . " at {$file}:{$line}" . self::RESET; - } - - $remaining = \count($trace) - $this->maxTraceFrames; - if ($remaining > 0) { - $lines[] = self::DIM . " ... {$remaining} more" . self::RESET; - } - } - - $lines[] = ''; - $lines[] = self::DIM . str_repeat('─', $this->width) . self::RESET; - - fwrite($stream, implode(PHP_EOL, $lines) . PHP_EOL); - } - - private function header(Span $span, bool $hasError): string - { - $action = $span->getAction(); - $duration = $span->get('span.duration'); - $traceId = $span->get('span.trace_id'); - - $actionColor = $hasError ? self::RED : self::GREEN; - $actionStr = self::BOLD . $actionColor . $action . self::RESET; - - $parts = [$actionStr]; - - if (\is_float($duration)) { - $durationStr = $this->formatDuration($duration); - $durationColor = $this->durationColor($duration); - $parts[] = $durationColor . $durationStr . self::RESET; - } - - if (\is_string($traceId)) { - $short = substr($traceId, 0, 8); - $parts[] = self::DIM . $short . self::RESET; - } - - return implode(self::DIM . ' · ' . self::RESET, $parts); - } - - private function attribute(string $key, string|int|float|bool|null $value, int $padTo): string - { - $padded = str_pad($key, $padTo); - $keyStr = self::CYAN . $padded . self::RESET; - $valStr = self::WHITE . $this->formatValue($value) . self::RESET; - - return " {$keyStr} {$valStr}"; - } - - private function error(\Throwable $error): string - { - $type = $error::class; - $message = $error->getMessage(); - $file = $error->getFile(); - $line = $error->getLine(); - - return self::RED . self::BOLD . " {$type}" . self::RESET - . self::RED . ": {$message}" . self::RESET . PHP_EOL - . self::DIM . " at {$file}:{$line}" . self::RESET; - } - - private function formatValue(string|int|float|bool|null $value): string - { - return match (true) { - $value === null => 'null', - $value === true => 'true', - $value === false => 'false', - default => (string) $value, - }; - } - - private function formatDuration(float $seconds): string - { - if ($seconds >= 1.0) { - return round($seconds, 2) . 's'; - } - - return round($seconds * 1000, 1) . 'ms'; - } - - private function durationColor(float $seconds): string - { - if ($seconds >= 1.0) { - return self::RED; - } - - if ($seconds >= 0.1) { - return self::YELLOW; - } - - return self::GREEN; - } -} diff --git a/packages/span/src/Span/Exporter/Sentry.php b/packages/span/src/Span/Exporter/Sentry.php deleted file mode 100644 index a508fd445..000000000 --- a/packages/span/src/Span/Exporter/Sentry.php +++ /dev/null @@ -1,496 +0,0 @@ - SentryField::Tag, - * default => SentryField::Context, - * }; - * }); - * ``` - */ -class Sentry implements Exporter -{ - private static ?string $sdkVersion = null; - - /** - * Span levels that are exported. Anything below warning (info, debug) is skipped. - */ - private const array EXPORT_LEVELS = [Level::Warn, Level::Error, Level::Fatal]; - - /** - * Maximum number of chained exceptions (via getPrevious()) sent per event. - */ - private const int MAX_CHAIN_DEPTH = 10; - - private readonly string $endpoint; - private readonly string $publicKey; - private readonly string $projectId; - - /** @var Closure(string): SentryField */ - private readonly Closure $classifier; - - /** @var Closure(Span): bool */ - private readonly Closure $sampler; - - private readonly ClientInterface $client; - - private readonly RequestFactory $requestFactory; - - /** - * Create a new Sentry exporter. - * - * Sentry only exports spans at warning level or above; a custom sampler is composed (AND) - * with the built-in level filter, so it can further restrict — but not broaden — what is sent. - * - * @param Closure(Span): bool|null $sampler Optional additional filter, composed with the level filter. - * @param string $dsn Sentry DSN (e.g., https://key@sentry.io/123) - * @param string|null $environment Optional environment name (e.g., 'production') - * @param string|null $release Optional release/version identifier (e.g., commit hash) - * @param string|null $serverName Optional server name/identifier - * @param Closure(string): SentryField|null $classifier Optional callback to classify attributes - * @param ClientInterface|null $client Optional PSR-18 transport; defaults to `utopia-php/client` with its cURL adapter - */ - public function __construct( - ?Closure $sampler = null, - private readonly string $dsn = '', - private readonly ?string $environment = null, - private readonly ?string $release = null, - private readonly ?string $serverName = null, - ?Closure $classifier = null, - ?ClientInterface $client = null, - ) { - $this->classifier = $classifier ?? static fn(string $key): SentryField => SentryField::Context; - $this->client = $client ?? new HttpClient( - new CurlClient(options: [ - \CURLOPT_TIMEOUT_MS => 1000, - \CURLOPT_CONNECTTIMEOUT_MS => 500, - ]), - ); - $this->requestFactory = new RequestFactory(); - $this->sampler = static function (Span $span) use ($sampler): bool { - $level = Level::tryFrom((string) $span->get('level')); - if ($level === null || !\in_array($level, self::EXPORT_LEVELS, true)) { - return false; - } - return !$sampler instanceof \Closure || $sampler($span); - }; - if ($dsn === '') { - throw new \InvalidArgumentException('Sentry DSN is required'); - } - - $parsed = parse_url($dsn); - - if ($parsed === false) { - throw new \InvalidArgumentException('Invalid Sentry DSN'); - } - - $publicKey = $parsed['user'] ?? ''; - $host = $parsed['host'] ?? ''; - $projectId = ltrim($parsed['path'] ?? '', '/'); - - if ($publicKey === '' || $host === '' || $projectId === '') { - throw new \InvalidArgumentException('Invalid Sentry DSN: must include public key, host, and project ID'); - } - - $this->publicKey = $publicKey; - $this->projectId = $projectId; - - $scheme = $parsed['scheme'] ?? 'https'; - $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; - - $this->endpoint = "{$scheme}://{$host}{$port}/api/{$this->projectId}/envelope/"; - } - - public function sample(Span $span): bool - { - return ($this->sampler)($span); - } - - public function export(Span $span): void - { - $envelope = $this->buildEnvelope($span); - - if ($envelope === null) { - return; - } - - $this->sendWithClient($this->client, $envelope); - } - - private function sendWithClient(ClientInterface $client, string $envelope): void - { - $request = $this->requestFactory->body( - Method::POST, - $this->endpoint, - $envelope, - 'application/x-sentry-envelope', - ['X-Sentry-Auth' => "Sentry sentry_version=7, sentry_key={$this->publicKey}"], - ); - - try { - $response = $client->sendRequest($request); - $statusCode = $response->getStatusCode(); - - if ($statusCode >= 400) { - error_log("Sentry exporter: HTTP {$statusCode} - {$response->getBody()}"); - } - } catch (\Throwable $error) { - error_log('Sentry exporter: ' . $error->getMessage()); - } - } - - private function buildEnvelope(Span $span): ?string - { - $error = $span->getError(); - - if (!$error instanceof \Throwable) { - return null; - } - - $attributes = $span->getAttributes(); - - $traceId = (string) ($attributes['span.trace_id'] ?? ''); - $spanId = (string) ($attributes['span.id'] ?? ''); - $parentId = $attributes['span.parent_id'] ?? null; - $startedAt = (float) ($attributes['span.started_at'] ?? microtime(true)); - $finishedAt = (float) ($attributes['span.finished_at'] ?? microtime(true)); - $action = $span->getAction(); - - $traceContext = [ - 'trace_id' => $traceId, - 'span_id' => $spanId, - ]; - - if (\is_string($parentId)) { - $traceContext['parent_span_id'] = $parentId; - } - - $header = json_encode([ - 'event_id' => str_replace('-', '', $traceId), - 'sent_at' => date('c'), - 'dsn' => $this->dsn, - ]); - - $itemHeader = json_encode([ - 'type' => 'event', - 'content_type' => 'application/json', - ]); - - $contexts = [ - 'trace' => $traceContext, - 'runtime' => [ - 'name' => 'php', - 'version' => PHP_VERSION, - 'sapi' => PHP_SAPI, - ], - ]; - - $request = $this->buildRequest($attributes); - $response = $this->buildResponse($attributes); - - if ($response !== []) { - $contexts['response'] = $response; - } - - [$tags, $customContexts, $extra] = $this->classifyAttributes($attributes); - - if ($customContexts !== []) { - $contexts['custom'] = $customContexts; - } - - $level = Level::tryFrom((string) $attributes['level']) ?? Level::Error; - - $payloadData = [ - 'level' => SentryLevel::fromSpan($level)->value, - 'platform' => 'php', - 'sdk' => [ - 'name' => 'utopia-php/span', - 'version' => self::$sdkVersion ??= InstalledVersions::getVersion('utopia-php/span') ?? 'unknown', - ], - 'start_timestamp' => $startedAt, - 'timestamp' => $finishedAt, - 'transaction' => $action, - 'message' => $error->getMessage(), - 'contexts' => $contexts, - 'exception' => [ - 'values' => $this->buildExceptionValues($error, $level, \is_bool($attributes['span.handled'] ?? null) ? $attributes['span.handled'] : null), - ], - ]; - - if ($tags !== []) { - $payloadData['tags'] = $tags; - } - - if ($extra !== []) { - $payloadData['extra'] = $extra; - } - - if ($request !== []) { - $payloadData['request'] = $request; - } - - if ($this->environment !== null) { - $payloadData['environment'] = $this->environment; - } - - if ($this->release !== null) { - $payloadData['release'] = $this->release; - } - - if ($this->serverName !== null) { - $payloadData['server_name'] = $this->serverName; - } - - $payload = json_encode($payloadData); - - if ($header === false || $itemHeader === false || $payload === false) { - return null; - } - - return "{$header}\n{$itemHeader}\n{$payload}"; - } - - /** - * Build the Sentry exception values list, following the getPrevious() chain. - * - * Values are ordered oldest cause first (root cause at index 0, reported - * exception last), as required by the Sentry exception interface. Each value - * carries a mechanism; chained values are linked via exception_id/parent_id - * so Sentry renders the cause tree. - * - * @return array> - */ - private function buildExceptionValues(\Throwable $error, Level $level, ?bool $handledOverride = null): array - { - $chain = []; - for ($current = $error; $current instanceof \Throwable && \count($chain) < self::MAX_CHAIN_DEPTH; $current = $current->getPrevious()) { - $chain[] = $current; - } - - // A 'span.handled' attribute states the handling state explicitly; the - // level is only a fallback heuristic (fatal implies a process-level - // handler, anything reported at warning/error implies user code). - $handled = $handledOverride ?? ($level !== Level::Fatal); - $chained = \count($chain) > 1; - $values = []; - - // $chain[0] is the reported (outermost) exception — the root of the - // mechanism tree, so it gets exception_id 0. - foreach ($chain as $id => $exception) { - $mechanism = [ - 'type' => 'generic', - 'handled' => $handled, - ]; - - if ($chained) { - $mechanism['exception_id'] = $id; - if ($id > 0) { - $mechanism['parent_id'] = $id - 1; - $mechanism['source'] = '__previous__'; - } - } - - $value = [ - 'type' => $exception::class, - 'value' => $exception->getMessage(), - 'stacktrace' => ['frames' => $this->buildFrames($exception)], - 'mechanism' => $mechanism, - ]; - - $namespaceEnd = strrpos($exception::class, '\\'); - if ($namespaceEnd !== false) { - $value['module'] = substr($exception::class, 0, $namespaceEnd); - } - - $values[] = $value; - } - - return array_reverse($values); - } - - /** - * Build Sentry stacktrace frames for one throwable, oldest call first, - * ending with the throw site. - * - * @return array> - */ - private function buildFrames(\Throwable $error): array - { - $trace = $error->getTrace(); - - $frames = []; - foreach (array_reverse($trace) as $frame) { - if (!isset($frame['file'])) { - continue; - } - $frames[] = [ - 'filename' => $frame['file'], - 'lineno' => $frame['line'] ?? 0, - 'in_app' => !str_contains($frame['file'], '/vendor/'), - 'function' => $this->formatFunction($frame), - ]; - } - - // PHP traces describe call sites, not the throw site itself — append it. - // Its enclosing function is the callee recorded in the first trace frame. - $throwSite = [ - 'filename' => $error->getFile(), - 'lineno' => $error->getLine(), - 'in_app' => !str_contains($error->getFile(), '/vendor/'), - ]; - - if (isset($trace[0]['function'])) { - $throwSite['function'] = $this->formatFunction($trace[0]); - } - - $frames[] = $throwSite; - - return $frames; - } - - /** - * @param array{function: string, class?: class-string, type?: string} $frame - */ - private function formatFunction(array $frame): string - { - return isset($frame['class']) - ? $frame['class'] . ($frame['type'] ?? '::') . $frame['function'] - : $frame['function']; - } - - private const array HANDLED_HTTP_KEYS = [ - 'http.url', - 'http.method', - 'http.query', - 'http.response.status_code', - ]; - - /** - * Classify attributes into tags, contexts, and extra based on the classifier. - * - * @param array $attributes - * @return array{array, array, array} - */ - private function classifyAttributes(array $attributes): array - { - $tags = []; - $contexts = []; - $extra = []; - - foreach ($attributes as $key => $value) { - // Skip internal span attributes and level (handled in payload) - if (str_starts_with((string) $key, 'span.')) { - continue; - } - if ($key === 'level') { - continue; - } - // Skip only the HTTP attributes we handle in buildRequest/buildResponse - if (\in_array($key, self::HANDLED_HTTP_KEYS, true)) { - continue; - } - - $field = ($this->classifier)($key); - - switch ($field) { - case SentryField::Tag: - // Tags must be strings, max 200 chars - if (\is_scalar($value) || $value === null) { - $tags[$key] = substr((string) $value, 0, 200); - } - break; - case SentryField::Context: - $contexts[$key] = $value; - break; - case SentryField::Extra: - $extra[$key] = $value; - break; - } - } - - return [$tags, $contexts, $extra]; - } - - /** - * @param array $attributes - * @return array - */ - private function buildRequest(array $attributes): array - { - $request = []; - - if (isset($attributes['http.url']) && \is_string($attributes['http.url'])) { - $request['url'] = $attributes['http.url']; - } - - if (isset($attributes['http.method']) && \is_string($attributes['http.method'])) { - $request['method'] = $attributes['http.method']; - } - - if (isset($attributes['http.query']) && \is_string($attributes['http.query'])) { - $request['query_string'] = $attributes['http.query']; - } - - return $request; - } - - /** - * @param array $attributes - * @return array - */ - private function buildResponse(array $attributes): array - { - $response = []; - - if (isset($attributes['http.response.status_code']) && \is_int($attributes['http.response.status_code'])) { - $response['status_code'] = $attributes['http.response.status_code']; - } - - return $response; - } -} diff --git a/packages/span/src/Span/Exporter/Sentry/Level.php b/packages/span/src/Span/Exporter/Sentry/Level.php deleted file mode 100644 index 975127d6f..000000000 --- a/packages/span/src/Span/Exporter/Sentry/Level.php +++ /dev/null @@ -1,35 +0,0 @@ - self::Debug, - SpanLevel::Info => self::Info, - SpanLevel::Warn => self::Warning, - SpanLevel::Error => self::Error, - SpanLevel::Fatal => self::Fatal, - }; - } -} diff --git a/packages/span/src/Span/Exporter/SentryField.php b/packages/span/src/Span/Exporter/SentryField.php deleted file mode 100644 index 100841542..000000000 --- a/packages/span/src/Span/Exporter/SentryField.php +++ /dev/null @@ -1,19 +0,0 @@ -sampler = $sampler ?? static fn(Span $span): bool => true; - } - - public function sample(Span $span): bool - { - return ($this->sampler)($span); - } - - public function export(Span $span): void - { - $attributes = $span->getAttributes(); - $ordered = []; - if (\array_key_exists('level', $attributes)) { - $ordered['level'] = $attributes['level']; - unset($attributes['level']); - } - $ordered['action'] = $span->getAction(); - - $data = $ordered + $attributes; - $error = $span->getError(); - - if ($error instanceof \Throwable) { - $data['error.type'] = $error::class; - $data['error.message'] = $error->getMessage(); - $data['error.code'] = $error->getCode(); - $data['error.file'] = $error->getFile(); - $data['error.line'] = $error->getLine(); - - $trace = $error->getTrace(); - $limited = \array_slice($trace, 0, $this->maxTraceFrames); - $data['error.trace'] = array_map(fn(array $frame): array => [ - 'file' => $frame['file'] ?? null, - 'line' => $frame['line'] ?? null, - 'function' => $frame['function'], - ], $limited); - - if (\count($trace) > $this->maxTraceFrames) { - $data['error.trace_truncated'] = \count($trace) - $this->maxTraceFrames; - } - } - - $output = json_encode($data, JSON_UNESCAPED_SLASHES); - - if ($output === false) { - return; - } - - $stream = $error instanceof \Throwable ? STDERR : STDOUT; - - fwrite($stream, $output . PHP_EOL); - } -} diff --git a/packages/span/src/Span/Level.php b/packages/span/src/Span/Level.php deleted file mode 100644 index 64f758f8c..000000000 --- a/packages/span/src/Span/Level.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - private static array $exporters = []; - - /** - * @var array - */ - private array $attributes = []; - - private ?Throwable $error = null; - - public function __construct(private readonly string $action = 'unknown') - { - $this->attributes['span.trace_id'] = bin2hex(random_bytes(16)); - $this->attributes['span.id'] = bin2hex(random_bytes(8)); - $this->attributes['span.started_at'] = microtime(true); - } - - /** - * Set (or clear) the storage backend for span context. - * - * Call once at application startup before creating spans. Pass null to clear. - * - * @param Storage|null $storage Use Storage\Auto for automatic detection, or null to clear - */ - public static function setStorage(?Storage $storage): void - { - self::$storage = $storage; - } - - /** - * Replace all exporters. - * - * Exporters receive finished spans. Each exporter decides whether to export - * via its own {@see Exporter::sample()} method. - * - * @param Exporter ...$exporters Exporters to register, replacing any previously set - */ - public static function setExporters(Exporter ...$exporters): void - { - self::$exporters = $exporters; - } - - /** - * Initialize a new span and set it as current. - * - * Creates a new span with unique trace and span IDs. If a traceparent header - * is provided, the span will continue that trace (for distributed tracing). - * - * @param string $action What this span represents (e.g., 'http.request', 'db.query') - * @param string|null $traceparent W3C traceparent header from incoming request - * @return self The new span instance - */ - public static function init(string $action, ?string $traceparent = null): self - { - $span = new self($action); - - if ($traceparent !== null) { - $parts = explode('-', $traceparent); - - if ( - \count($parts) === 4 - && $parts[0] === '00' - && \strlen($parts[1]) === 32 && ctype_xdigit($parts[1]) - && \strlen($parts[2]) === 16 && ctype_xdigit($parts[2]) - && \strlen($parts[3]) === 2 && ctype_xdigit($parts[3]) - ) { - $span->attributes['span.trace_id'] = $parts[1]; - $span->attributes['span.parent_id'] = $parts[2]; - } - } - - if (self::$storage instanceof \Utopia\Span\Storage\Storage) { - self::$storage->set($span); - } - - return $span; - } - - /** - * Get the current span from storage. - * - * @return self|null The current span, or null if no span is active - */ - public static function current(): ?self - { - return self::$storage?->get(); - } - - /** - * Set an attribute on the current span. - * - * Convenience method to add attributes without holding a span reference. - * Does nothing if no span is active. - * - * @param string $key Attribute name (e.g., 'user.id', 'http.status') - * @param string|int|float|bool|null $value Attribute value (scalars only) - */ - public static function add(string $key, string|int|float|bool|null $value): void - { - self::current()?->set($key, $value); - } - - /** - * Get the traceparent header value from the current span. - * - * Use this to propagate trace context to downstream services. - * - * @return string|null W3C traceparent header value, or null if no span is active - */ - public static function traceparent(): ?string - { - return self::current()?->getTraceparent(); - } - - /** - * Set an attribute on this span. - * - * @param string $key Attribute name (e.g., 'user.id', 'http.status') - * @param string|int|float|bool|null $value Attribute value (scalars only) - * @return self For method chaining - */ - public function set(string $key, string|int|float|bool|null $value): self - { - $this->attributes[$key] = $value; - return $this; - } - - /** - * Capture an exception on this span. - * - * Exporters can access the full exception including stacktrace via getError(). - * - * @param Throwable $error The exception to capture - * @return self For method chaining - */ - public function setError(Throwable $error): self - { - $this->error = $error; - return $this; - } - - /** - * Get the captured exception. - * - * @return Throwable|null The captured exception, or null if none - */ - public function getError(): ?Throwable - { - return $this->error; - } - - /** - * Get the span action. - * - * @return string What this span represents - */ - public function getAction(): string - { - return $this->action; - } - - /** - * Get an attribute value. - * - * @param string $key Attribute name - * @return string|int|float|bool|null The value, or null if not set - */ - public function get(string $key): string|int|float|bool|null - { - return $this->attributes[$key] ?? null; - } - - /** - * Get the W3C Trace Context traceparent header value - * - * Returns a traceparent header in the format: {version}-{trace_id}-{parent_id}-{flags} - * Example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 - */ - public function getTraceparent(): string - { - return \sprintf( - '00-%s-%s-01', - $this->attributes['span.trace_id'], - $this->attributes['span.id'], - ); - } - - /** - * Get all attributes - * - * @return array - */ - public function getAttributes(): array - { - return $this->attributes; - } - - /** - * Finish this span and export it. - * - * Sets span.finished_at and span.duration, then sends to all exporters - * that pass their sampler (if any). Clears the current span from storage. - * - * @param Level|null $level Level to export for this span - * @param Throwable|null $error Exception that caused the span to fail - */ - public function finish(?Level $level = null, ?Throwable $error = null): void - { - if ($error instanceof \Throwable) { - $this->setError($error); - } - - $finishedAt = microtime(true); - /** @var float $startedAt */ - $startedAt = $this->attributes['span.started_at']; - - $this->attributes['span.finished_at'] = $finishedAt; - $this->attributes['span.duration'] = $finishedAt - $startedAt; - - $level ??= $this->error instanceof \Throwable ? Level::Error : Level::Info; - $this->attributes['level'] = $level->value; - - foreach (self::$exporters as $exporter) { - try { - if ($exporter->sample($this)) { - $exporter->export($this); - } - } catch (\Throwable) { - // Tracing should never break the application - } - } - - if (self::$storage instanceof \Utopia\Span\Storage\Storage) { - self::$storage->set(null); - } - } -} diff --git a/packages/span/src/Span/Storage/Auto.php b/packages/span/src/Span/Storage/Auto.php deleted file mode 100644 index 64799c515..000000000 --- a/packages/span/src/Span/Storage/Auto.php +++ /dev/null @@ -1,49 +0,0 @@ -memory = new Memory(); - - if (\extension_loaded('swoole')) { - $this->coroutine = new Coroutine(); - } - } - - public function get(): ?Span - { - if ($this->coroutine instanceof \Utopia\Span\Storage\Coroutine && $this->isInCoroutine()) { - return $this->coroutine->get(); - } - - return $this->memory->get(); - } - - public function set(?Span $span): void - { - if ($this->coroutine instanceof \Utopia\Span\Storage\Coroutine && $this->isInCoroutine()) { - $this->coroutine->set($span); - return; - } - - $this->memory->set($span); - } - - private function isInCoroutine(): bool - { - return \Swoole\Coroutine::getCid() !== -1; - } -} diff --git a/packages/span/src/Span/Storage/Coroutine.php b/packages/span/src/Span/Storage/Coroutine.php deleted file mode 100644 index 0899a4d9d..000000000 --- a/packages/span/src/Span/Storage/Coroutine.php +++ /dev/null @@ -1,52 +0,0 @@ -span; - } - - public function set(?Span $span): void - { - $this->span = $span; - } -} diff --git a/packages/span/src/Span/Storage/Storage.php b/packages/span/src/Span/Storage/Storage.php deleted file mode 100644 index 9b17422b3..000000000 --- a/packages/span/src/Span/Storage/Storage.php +++ /dev/null @@ -1,30 +0,0 @@ -export($span); - - $this->assertTrue(true); - } - - public function testExportAcceptsAnySpan(): void - { - $exporter = new None(); - - $span1 = new Span(); - $span1->set('action', 'test'); - - $span2 = new Span(); - $span2->setError(new \RuntimeException('Error')); - - // Should accept any span without error - $exporter->export($span1); - $exporter->export($span2); - - $this->assertTrue(true); - } - - public function testMultipleExportsDoNotAccumulate(): void - { - $exporter = new None(); - - for ($i = 0; $i < 100; $i++) { - $span = new Span(); - $exporter->export($span); - } - - // None exporter should not store anything - $this->assertTrue(true); - } -} diff --git a/packages/span/tests/Exporter/PrettyTest.php b/packages/span/tests/Exporter/PrettyTest.php deleted file mode 100644 index c26d5ae80..000000000 --- a/packages/span/tests/Exporter/PrettyTest.php +++ /dev/null @@ -1,74 +0,0 @@ -finish(); - - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportDoesNotThrow(): void - { - $exporter = new Pretty(); - $span = new Span(); - - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesAllAttributeTypes(): void - { - $exporter = new Pretty(); - $span = new Span('test.types'); - $span->set('string', 'value'); - $span->set('int', 42); - $span->set('float', 3.14); - $span->set('bool', true); - $span->set('null', null); - - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesError(): void - { - $exporter = new Pretty(); - $span = new Span('test.error'); - $span->setError(new \RuntimeException('Test error', 42)); - - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportIncludesSpanMetadata(): void - { - new Pretty(); - $span = new Span('test.meta'); - $span->finish(); - - $attributes = $span->getAttributes(); - - $this->assertArrayHasKey('span.trace_id', $attributes); - $this->assertArrayHasKey('span.id', $attributes); - $this->assertArrayHasKey('span.started_at', $attributes); - $this->assertArrayHasKey('span.finished_at', $attributes); - $this->assertArrayHasKey('span.duration', $attributes); - } -} diff --git a/packages/span/tests/Exporter/SentryTest.php b/packages/span/tests/Exporter/SentryTest.php deleted file mode 100644 index d30cedc0a..000000000 --- a/packages/span/tests/Exporter/SentryTest.php +++ /dev/null @@ -1,435 +0,0 @@ - - */ - private function buildPayload(Span $span): array - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - - $method = new \ReflectionMethod(Sentry::class, 'buildEnvelope'); - $envelope = $method->invoke($exporter, $span); - - $this->assertIsString($envelope); - - $lines = explode("\n", $envelope); - $this->assertCount(3, $lines); - - $payload = json_decode($lines[2], true); - $this->assertIsArray($payload); - - return $payload; - } - - public function testConstructorParsesDsn(): void - { - $exporter = new Sentry(dsn: 'https://publickey@sentry.io/123456'); - - $this->assertInstanceOf(Sentry::class, $exporter); - } - - public function testConstructorDefaultsToUtopiaClient(): void - { - $exporter = new Sentry(dsn: 'https://publickey@sentry.io/123456'); - $property = new \ReflectionProperty(Sentry::class, 'client'); - - $this->assertInstanceOf(HttpClient::class, $property->getValue($exporter)); - } - - public function testConstructorThrowsOnInvalidDsn(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid Sentry DSN'); - - new Sentry(dsn: 'http:///invalid'); - } - - public function testConstructorThrowsOnEmptyDsn(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Sentry DSN is required'); - - new Sentry(); - } - - public function testConstructorThrowsOnDsnMissingPublicKey(): void - { - $this->expectException(\InvalidArgumentException::class); - - new Sentry(dsn: 'https://sentry.io/123'); - } - - public function testConstructorThrowsOnDsnMissingProjectId(): void - { - $this->expectException(\InvalidArgumentException::class); - - new Sentry(dsn: 'https://key@sentry.io'); - } - - public function testConstructorHandlesDsnWithPort(): void - { - $exporter = new Sentry(dsn: 'https://publickey@sentry.example.com:9000/123'); - - $this->assertInstanceOf(Sentry::class, $exporter); - } - - public function testConstructorHandlesHttpDsn(): void - { - $exporter = new Sentry(dsn: 'http://publickey@localhost/123'); - - $this->assertInstanceOf(Sentry::class, $exporter); - } - - public function testExportDoesNotThrowWithValidSpan(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->set('action', 'test'); - $span->finish(); - - // Export will fail to connect but should not throw - // (curl will timeout but we catch that) - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesSpanWithParentId(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->set('span.parent_id', 'abc123def456'); - $span->finish(); - - // Should not throw - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesSpanWithError(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->setError(new \RuntimeException('Test error')); - $span->finish(); - - // Should not throw - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportUsesInjectedPsr18Client(): void - { - $client = new class implements ClientInterface { - public ?RequestInterface $request = null; - - public function sendRequest(RequestInterface $request): ResponseInterface - { - $this->request = $request; - - return new ResponseFactory()->createResponse(202); - } - }; - $exporter = new Sentry( - dsn: 'https://publickey@sentry.example.com:9000/123', - client: $client, - ); - $span = new Span('test'); - $span->finish(error: new \RuntimeException('Test error')); - - $exporter->export($span); - - $this->assertNotNull($client->request); - $this->assertSame('POST', $client->request->getMethod()); - $this->assertSame('https://sentry.example.com:9000/api/123/envelope/', (string) $client->request->getUri()); - $this->assertSame('application/x-sentry-envelope', $client->request->getHeaderLine('Content-Type')); - $this->assertSame( - 'Sentry sentry_version=7, sentry_key=publickey', - $client->request->getHeaderLine('X-Sentry-Auth'), - ); - $this->assertStringContainsString('"type":"event"', (string) $client->request->getBody()); - $this->assertStringContainsString('"value":"Test error"', (string) $client->request->getBody()); - } - - public function testExportHandlesSpanWithAllAttributes(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->set('action', 'http.request'); - $span->set('user.id', '123'); - $span->set('request.method', 'POST'); - $span->set('response.status', 200); - $span->set('span.parent_id', 'parent123'); - $span->finish(); - - // Should not throw - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesHttpConventionAttributes(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span('http.request'); - $span->set('http.url', 'https://api.example.com/users'); - $span->set('http.method', 'POST'); - $span->set('http.query', 'page=1&limit=10'); - $span->set('http.response.status_code', 201); - $span->setError(new \RuntimeException('Request failed')); - $span->finish(); - - // Should not throw - HTTP attributes are mapped to Sentry request/response - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testSampleSkipsInfoLevelSpans(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->finish(); - - $this->assertFalse($exporter->sample($span)); - } - - public function testSampleSendsWarningLevelSpans(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->finish(level: Level::Warn, error: new \RuntimeException('Heads up')); - - $this->assertTrue($exporter->sample($span)); - } - - public function testSampleSendsErrorLevelSpans(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->setError(new \RuntimeException('Boom')); - $span->finish(); - - $this->assertTrue($exporter->sample($span)); - } - - public function testSampleSkipsDowngradedErrorSpans(): void - { - $exporter = new Sentry(dsn: 'https://key@sentry.io/123'); - $span = new Span(); - $span->finish(level: Level::Info, error: new \RuntimeException('Handled, not worth reporting')); - - $this->assertFalse($exporter->sample($span)); - } - - public function testSampleComposesCustomSamplerWithLevelFilter(): void - { - $exporter = new Sentry( - sampler: fn(Span $span): bool => $span->getAction() === 'keep', - dsn: 'https://key@sentry.io/123', - ); - - $kept = new Span('keep'); - $kept->finish(level: Level::Warn, error: new \RuntimeException('Test')); - - $dropped = new Span('drop'); - $dropped->finish(level: Level::Warn, error: new \RuntimeException('Test')); - - $this->assertTrue($exporter->sample($kept)); - $this->assertFalse($exporter->sample($dropped)); - } - - public function testExportWithClassifier(): void - { - $exporter = new Sentry( - dsn: 'https://key@sentry.io/123', - classifier: fn(string $key): SentryField => match (true) { - str_starts_with($key, 'tenant.') => SentryField::Tag, - str_starts_with($key, 'user.') => SentryField::Context, - default => SentryField::Extra, - }, - ); - - $span = new Span('api.request'); - $span->set('tenant.id', 'acme-corp'); - $span->set('user.id', '12345'); - $span->set('user.email', 'test@example.com'); - $span->set('debug.info', 'some debug data'); - $span->setError(new \RuntimeException('Test error')); - $span->finish(); - - // Should not throw - classifier distributes attributes correctly - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testEnvelopeContainsChainedExceptions(): void - { - $root = new \LogicException('root cause'); - $middle = new \InvalidArgumentException('middle', 0, $root); - $outer = new \RuntimeException('outer', 0, $middle); - - $span = new Span('test'); - $span->finish(error: $outer); - - $values = $this->buildPayload($span)['exception']['values']; - - $this->assertCount(3, $values); - - // Oldest cause first, reported exception last - $this->assertSame(\LogicException::class, $values[0]['type']); - $this->assertSame('root cause', $values[0]['value']); - $this->assertSame(\InvalidArgumentException::class, $values[1]['type']); - $this->assertSame(\RuntimeException::class, $values[2]['type']); - $this->assertSame('outer', $values[2]['value']); - - // The reported exception is the root of the mechanism tree (id 0) - $this->assertSame(0, $values[2]['mechanism']['exception_id']); - $this->assertArrayNotHasKey('parent_id', $values[2]['mechanism']); - $this->assertSame(1, $values[1]['mechanism']['exception_id']); - $this->assertSame(0, $values[1]['mechanism']['parent_id']); - $this->assertSame('__previous__', $values[1]['mechanism']['source']); - $this->assertSame(2, $values[0]['mechanism']['exception_id']); - $this->assertSame(1, $values[0]['mechanism']['parent_id']); - - foreach ($values as $value) { - $this->assertNotEmpty($value['stacktrace']['frames']); - } - } - - public function testEnvelopeSingleExceptionHasNoChainIds(): void - { - $span = new Span('test'); - $span->finish(error: new \RuntimeException('alone')); - - $values = $this->buildPayload($span)['exception']['values']; - - $this->assertCount(1, $values); - $this->assertSame('generic', $values[0]['mechanism']['type']); - $this->assertArrayNotHasKey('exception_id', $values[0]['mechanism']); - $this->assertArrayNotHasKey('parent_id', $values[0]['mechanism']); - } - - public function testEnvelopeMechanismHandledReflectsLevel(): void - { - $handledSpan = new Span('test'); - $handledSpan->finish(level: Level::Error, error: new \RuntimeException('caught')); - - $values = $this->buildPayload($handledSpan)['exception']['values']; - $this->assertTrue($values[0]['mechanism']['handled']); - - $fatalSpan = new Span('test'); - $fatalSpan->finish(level: Level::Fatal, error: new \RuntimeException('crashed')); - - $values = $this->buildPayload($fatalSpan)['exception']['values']; - $this->assertFalse($values[0]['mechanism']['handled']); - } - - public function testEnvelopeMechanismHandledAttributeOverridesLevel(): void - { - $unhandledError = new Span('test'); - $unhandledError->set('span.handled', false); - $unhandledError->finish(level: Level::Error, error: new \RuntimeException('escaped')); - - $values = $this->buildPayload($unhandledError)['exception']['values']; - $this->assertFalse($values[0]['mechanism']['handled']); - - $handledFatal = new Span('test'); - $handledFatal->set('span.handled', true); - $handledFatal->finish(level: Level::Fatal, error: new \RuntimeException('caught but fatal')); - - $values = $this->buildPayload($handledFatal)['exception']['values']; - $this->assertTrue($values[0]['mechanism']['handled']); - } - - public function testEnvelopeIncludesExceptionModule(): void - { - $span = new Span('test'); - $span->finish(error: new NamespacedTestException('namespaced')); - - $values = $this->buildPayload($span)['exception']['values']; - - $this->assertSame(NamespacedTestException::class, $values[0]['type']); - $this->assertSame(__NAMESPACE__, $values[0]['module']); - - $globalSpan = new Span('test'); - $globalSpan->finish(error: new \RuntimeException('global')); - - $values = $this->buildPayload($globalSpan)['exception']['values']; - $this->assertArrayNotHasKey('module', $values[0]); - } - - public function testEnvelopeFramesCarryFunctionNames(): void - { - $error = $this->throwAndCatch(); - - $span = new Span('test'); - $span->finish(error: $error); - - $frames = $this->buildPayload($span)['exception']['values'][0]['stacktrace']['frames']; - - $this->assertNotEmpty($frames); - - foreach ($frames as $frame) { - if (\array_key_exists('function', $frame)) { - $this->assertNotSame('', $frame['function']); - } - } - - // The throw-site frame is last and names its enclosing function - $throwSite = end($frames); - $this->assertSame(self::class . '->throwAndCatch', $throwSite['function']); - $this->assertSame(__FILE__, $throwSite['filename']); - } - - public function testEnvelopeCapsExceptionChainDepth(): void - { - $error = new \RuntimeException('level 0'); - for ($i = 1; $i < 15; $i++) { - $error = new \RuntimeException("level {$i}", 0, $error); - } - - $span = new Span('test'); - $span->finish(error: $error); - - $values = $this->buildPayload($span)['exception']['values']; - - $this->assertCount(10, $values); - // Reported exception is always kept (last); the deepest causes are dropped - $this->assertSame('level 14', $values[9]['value']); - $this->assertSame('level 5', $values[0]['value']); - } - - private function throwAndCatch(): \Throwable - { - try { - throw new \RuntimeException('thrown here'); - } catch (\RuntimeException $error) { - return $error; - } - } -} diff --git a/packages/span/tests/Exporter/StdoutTest.php b/packages/span/tests/Exporter/StdoutTest.php deleted file mode 100644 index b1947b265..000000000 --- a/packages/span/tests/Exporter/StdoutTest.php +++ /dev/null @@ -1,82 +0,0 @@ -set('action', 'test.action'); - $span->finish(); - - ob_start(); - $exporter->export($span); - ob_get_clean(); - - // Output goes to STDOUT, not output buffer in CLI - // Just verify no exception is thrown - $this->assertTrue(true); - } - - public function testExportDoesNotThrow(): void - { - $exporter = new Stdout(); - $span = new Span(); - - // Capture STDOUT to prevent test output pollution - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesAllAttributeTypes(): void - { - $exporter = new Stdout(); - $span = new Span(); - $span->set('string', 'value'); - $span->set('int', 42); - $span->set('float', 3.14); - $span->set('bool', true); - $span->set('null', null); - - // Should not throw - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportHandlesError(): void - { - $exporter = new Stdout(); - $span = new Span(); - $span->setError(new \RuntimeException('Test error', 42)); - - // Should not throw - $exporter->export($span); - - $this->assertTrue(true); - } - - public function testExportIncludesSpanMetadata(): void - { - new Stdout(); - $span = new Span(); - $span->finish(); - - $attributes = $span->getAttributes(); - - $this->assertArrayHasKey('span.trace_id', $attributes); - $this->assertArrayHasKey('span.id', $attributes); - $this->assertArrayHasKey('span.started_at', $attributes); - $this->assertArrayHasKey('span.finished_at', $attributes); - $this->assertArrayHasKey('span.duration', $attributes); - } -} diff --git a/packages/span/tests/SpanTest.php b/packages/span/tests/SpanTest.php deleted file mode 100644 index 400846628..000000000 --- a/packages/span/tests/SpanTest.php +++ /dev/null @@ -1,673 +0,0 @@ -get('span.trace_id'); - $spanId = $span->get('span.id'); - $startedAt = $span->get('span.started_at'); - - $this->assertIsString($traceId); - $this->assertIsString($spanId); - $this->assertIsFloat($startedAt); - $this->assertSame(32, \strlen($traceId)); - $this->assertSame(16, \strlen($spanId)); - } - - public function testSetAndGet(): void - { - $span = new Span(); - - $span->set('key', 'value'); - - $this->assertSame('value', $span->get('key')); - } - - public function testSetReturnsself(): void - { - $span = new Span(); - - $result = $span->set('key', 'value'); - - $this->assertSame($span, $result); - } - - public function testGetReturnsNullForMissingKey(): void - { - $span = new Span(); - - $this->assertNull($span->get('nonexistent')); - } - - public function testSetAcceptsString(): void - { - $span = new Span(); - $span->set('key', 'string value'); - - $this->assertSame('string value', $span->get('key')); - } - - public function testSetAcceptsInt(): void - { - $span = new Span(); - $span->set('key', 42); - - $this->assertSame(42, $span->get('key')); - } - - public function testSetAcceptsFloat(): void - { - $span = new Span(); - $span->set('key', 3.14); - - $this->assertSame(3.14, $span->get('key')); - } - - public function testSetAcceptsBool(): void - { - $span = new Span(); - $span->set('key', true); - - $this->assertSame(true, $span->get('key')); - } - - public function testSetAcceptsNull(): void - { - $span = new Span(); - $span->set('key', null); - - $this->assertNull($span->get('key')); - } - - public function testGetAttributesReturnsAllAttributes(): void - { - $span = new Span(); - $span->set('key1', 'value1'); - $span->set('key2', 'value2'); - - $attributes = $span->getAttributes(); - - $this->assertSame('value1', $attributes['key1']); - $this->assertSame('value2', $attributes['key2']); - $this->assertArrayHasKey('span.trace_id', $attributes); - $this->assertArrayHasKey('span.id', $attributes); - $this->assertArrayHasKey('span.started_at', $attributes); - } - - public function testSetErrorStoresThrowable(): void - { - $span = new Span(); - $error = new RuntimeException('Test error', 42); - - $span->setError($error); - - $this->assertSame($error, $span->getError()); - } - - public function testSetErrorReturnsSelf(): void - { - $span = new Span(); - $error = new RuntimeException('Test error'); - - $result = $span->setError($error); - - $this->assertSame($span, $result); - } - - public function testFinishSetsFinishedAtAndDuration(): void - { - $span = new Span(); - - $this->assertNull($span->get('span.finished_at')); - $this->assertNull($span->get('span.duration')); - - $span->finish(); - - $this->assertIsFloat($span->get('span.finished_at')); - $this->assertIsFloat($span->get('span.duration')); - } - - public function testFinishCalculatesDurationCorrectly(): void - { - $span = new Span(); - usleep(10000); // 10ms - $span->finish(); - - $duration = $span->get('span.duration'); - $this->assertIsFloat($duration); - $this->assertGreaterThan(0.009, $duration); - $this->assertLessThan(0.1, $duration); - } - - public function testFinishExportsToAllExporters(): void - { - $exported1 = []; - $exported2 = []; - - $exporter1 = $this->createExporter($exported1); - $exporter2 = $this->createExporter($exported2); - - Span::setExporters($exporter1, $exporter2); - - $span = Span::init('test'); - $span->finish(); - - $this->assertCount(1, $exported1); - $this->assertCount(1, $exported2); - } - - public function testFinishClearsCurrentSpan(): void - { - $span = Span::init('test'); - - $this->assertSame($span, Span::current()); - - $span->finish(); - - $this->assertNull(Span::current()); - } - - public function testInitCreatesAndStoresSpan(): void - { - $span = Span::init('test'); - - $this->assertInstanceOf(Span::class, $span); - $this->assertSame($span, Span::current()); - } - - public function testCurrentReturnsNullWhenNoSpan(): void - { - $this->assertNull(Span::current()); - } - - public function testAddSetsAttributeOnCurrentSpan(): void - { - $span = Span::init('test'); - - Span::add('key', 'value'); - - $this->assertSame('value', $span->get('key')); - } - - public function testAddDoesNothingWhenNoCurrentSpan(): void - { - // Should not throw - Span::add('key', 'value'); - - $this->assertNull(Span::current()); - } - - public function testFinishAcceptsError(): void - { - $span = new Span(); - $error = new RuntimeException('Test'); - - $span->finish(error: $error); - - $this->assertSame($error, $span->getError()); - } - - public function testFinishWithErrorSetsLevelError(): void - { - $span = new Span(); - - $span->finish(error: new RuntimeException('Test')); - - $this->assertSame('error', $span->get('level')); - } - - public function testFinishWithErrorExportsErrorSpan(): void - { - $exported = []; - $exporter = $this->createExporter($exported); - $error = new RuntimeException('Test'); - - Span::setExporters($exporter); - - $span = Span::init('test'); - $span->finish(error: $error); - - $this->assertCount(1, $exported); - $this->assertSame($error, $exported[0]->getError()); - } - - public function testSamplerFiltersExport(): void - { - $exported = []; - $exporter = $this->createExporter( - $exported, - fn(Span $s): bool => $s->getError() instanceof \Throwable, - ); - - Span::setExporters($exporter); - - $span1 = Span::init('test'); - $span1->finish(); - - $span2 = Span::init('test'); - $span2->setError(new RuntimeException('Error')); - $span2->finish(); - - $this->assertCount(1, $exported); - } - - public function testSamplerReceivesSpan(): void - { - $exported = []; - $sampledSpan = null; - $exporter = $this->createExporter( - $exported, - function (Span $s) use (&$sampledSpan): bool { - $sampledSpan = $s; - return true; - }, - ); - - Span::setExporters($exporter); - - $span = Span::init('test'); - $span->finish(); - - $this->assertSame($span, $sampledSpan); - } - - public function testSetExportersReplacesExistingExporters(): void - { - $firstExported = []; - $secondExported = []; - $first = $this->createExporter($firstExported); - $second = $this->createExporter($secondExported); - - Span::setExporters($first); - Span::setExporters($second); - - $span = Span::init('test'); - $span->finish(); - - $this->assertCount(0, $firstExported); - $this->assertCount(1, $secondExported); - } - - public function testSetExportersWithMultipleExporters(): void - { - $exportedA = []; - $exportedB = []; - $a = $this->createExporter($exportedA); - $b = $this->createExporter($exportedB); - - Span::setExporters($a, $b); - - $span = Span::init('test'); - $span->finish(); - - $this->assertCount(1, $exportedA); - $this->assertCount(1, $exportedB); - } - - public function testSetExportersWithNoArgumentsClearsExporters(): void - { - $exported = []; - $exporter = $this->createExporter($exported); - - Span::setExporters($exporter); - Span::setExporters(); - - $span = Span::init('test'); - $span->finish(); - - $this->assertCount(0, $exported); - } - - public function testFluentInterface(): void - { - $span = new Span(); - - $result = $span - ->set('key1', 'value1') - ->set('key2', 'value2') - ->setError(new RuntimeException('Error')); - - $this->assertSame($span, $result); - } - - public function testSetStorageNullClearsStorage(): void - { - $exported = []; - $exporter = $this->createExporter($exported); - - Span::setExporters($exporter); - Span::init('test'); - - Span::setStorage(null); - - $this->assertNull(Span::current()); - - Span::setStorage(new Memory()); - $span = Span::init('test'); - $span->finish(); - - $this->assertCount(1, $exported); - } - - public function testInitWithoutStorageReturnsSpan(): void - { - Span::setStorage(null); - - $span = Span::init('test'); - - $this->assertInstanceOf(Span::class, $span); - } - - public function testCurrentWithoutStorageReturnsNull(): void - { - Span::setStorage(null); - - $this->assertNull(Span::current()); - } - - public function testSetOverwritesExistingAttribute(): void - { - $span = new Span(); - $span->set('key', 'value1'); - $span->set('key', 'value2'); - - $this->assertSame('value2', $span->get('key')); - } - - public function testMultipleSpansInSequence(): void - { - $exported = []; - $exporter = $this->createExporter($exported); - Span::setExporters($exporter); - - $span1 = Span::init('test'); - $span1->set('name', 'first'); - $span1->finish(); - - $span2 = Span::init('test'); - $span2->set('name', 'second'); - $span2->finish(); - - $this->assertCount(2, $exported); - $this->assertSame('first', $exported[0]->get('name')); - $this->assertSame('second', $exported[1]->get('name')); - } - - public function testFinishWithoutExportersDoesNotThrow(): void - { - Span::setExporters(); - - $span = Span::init('test'); - $span->finish(); - - $this->assertNull(Span::current()); - } - - public function testTraceIdIsUniqueBetweenSpans(): void - { - $span1 = new Span(); - $span2 = new Span(); - - $this->assertNotEquals( - $span1->get('span.trace_id'), - $span2->get('span.trace_id'), - ); - } - - public function testSpanIdIsUniqueBetweenSpans(): void - { - $span1 = new Span(); - $span2 = new Span(); - - $this->assertNotEquals( - $span1->get('span.id'), - $span2->get('span.id'), - ); - } - - public function testCanOverwriteBuiltInAttributes(): void - { - $span = new Span(); - $customTraceId = 'custom-trace-id-12345678'; - - $span->set('span.trace_id', $customTraceId); - - $this->assertSame($customTraceId, $span->get('span.trace_id')); - } - - public function testSetParentId(): void - { - $span = new Span(); - $parentId = 'abc123def456'; - - $span->set('span.parent_id', $parentId); - - $this->assertSame($parentId, $span->get('span.parent_id')); - } - - public function testAddWithAllScalarTypes(): void - { - $span = Span::init('test'); - - Span::add('string', 'value'); - Span::add('int', 42); - Span::add('float', 3.14); - Span::add('bool', false); - Span::add('null', null); - - $this->assertSame('value', $span->get('string')); - $this->assertSame(42, $span->get('int')); - $this->assertSame(3.14, $span->get('float')); - $this->assertFalse($span->get('bool')); - $this->assertNull($span->get('null')); - } - - public function testMultipleExportersWithIndependentSamplers(): void - { - $exportedYes = []; - $exportedNo = []; - - $yes = $this->createExporter($exportedYes, fn(Span $s): bool => true); - $no = $this->createExporter($exportedNo, fn(Span $s): bool => false); - - Span::setExporters($yes, $no); - - $span = Span::init('test'); - $span->finish(); - - $this->assertCount(1, $exportedYes); - $this->assertCount(0, $exportedNo); - } - - public function testSamplerCanFilterByDuration(): void - { - $exported = []; - $exporter = $this->createExporter( - $exported, - fn(Span $s): bool => $s->get('span.duration') > 0.005, - ); - - Span::setExporters($exporter); - - $fastSpan = Span::init('test'); - $fastSpan->finish(); - - $slowSpan = Span::init('test'); - usleep(6000); - $slowSpan->finish(); - - $this->assertCount(1, $exported); - } - - public function testGetTraceparentReturnsValidFormat(): void - { - $span = new Span(); - - $traceparent = $span->getTraceparent(); - - $parts = explode('-', $traceparent); - $this->assertCount(4, $parts); - $this->assertSame('00', $parts[0]); - $this->assertSame(32, \strlen($parts[1])); - $this->assertSame(16, \strlen($parts[2])); - $this->assertSame('01', $parts[3]); - } - - public function testGetTraceparentUsesSpanAttributes(): void - { - $span = new Span(); - $traceId = $span->get('span.trace_id'); - $spanId = $span->get('span.id'); - - $traceparent = $span->getTraceparent(); - - $this->assertSame("00-{$traceId}-{$spanId}-01", $traceparent); - } - - public function testTraceparentRoundTrip(): void - { - $span1 = new Span(); - $traceparent = $span1->getTraceparent(); - - $span2 = Span::init('test', $traceparent); - - $this->assertSame($span1->get('span.trace_id'), $span2->get('span.trace_id')); - $this->assertSame($span1->get('span.id'), $span2->get('span.parent_id')); - } - - public function testStaticTraceparentReturnsCurrentSpanTraceparent(): void - { - $span = Span::init('test'); - - $traceparent = Span::traceparent(); - - $this->assertSame($span->getTraceparent(), $traceparent); - } - - public function testStaticTraceparentReturnsNullWhenNoCurrentSpan(): void - { - $this->assertNull(Span::traceparent()); - } - - public function testInitWithTraceparentSetsTraceIdAndParentId(): void - { - $traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; - - $span = Span::init('test', $traceparent); - - $this->assertSame('0af7651916cd43dd8448eb211c80319c', $span->get('span.trace_id')); - $this->assertSame('b7ad6b7169203331', $span->get('span.parent_id')); - $this->assertSame($span, Span::current()); - } - - public function testInitWithNullTraceparentGeneratesNewTraceId(): void - { - $span = Span::init('test'); - - $traceId = $span->get('span.trace_id'); - $this->assertIsString($traceId); - $this->assertSame(32, \strlen($traceId)); - $this->assertNull($span->get('span.parent_id')); - } - - public function testInitWithInvalidTraceparentCreatesNewTrace(): void - { - $span = Span::init('test', 'invalid-traceparent'); - - $traceId = $span->get('span.trace_id'); - $this->assertIsString($traceId); - $this->assertSame(32, \strlen($traceId)); - $this->assertNull($span->get('span.parent_id')); - } - - public function testFinishSetsLevelInfoByDefault(): void - { - $span = new Span(); - $span->finish(); - - $this->assertSame('info', $span->get('level')); - } - - public function testFinishSetsLevelErrorWhenErrorSet(): void - { - $span = new Span(); - $span->setError(new RuntimeException('Test')); - $span->finish(); - - $this->assertSame('error', $span->get('level')); - } - - public function testFinishAcceptsLevelOverride(): void - { - $span = new Span(); - $span->finish(level: Level::Warn, error: new RuntimeException('Test')); - - $this->assertSame('warn', $span->get('level')); - } - - public function testFinishOwnsLevelAttribute(): void - { - $span = new Span(); - $span->set('level', 'warning'); - $span->finish(); - - $this->assertSame('info', $span->get('level')); - } - - public function testLevelNotSetBeforeFinish(): void - { - $span = new Span(); - - $this->assertNull($span->get('level')); - } - - /** - * @param array $exported - * @param Closure(Span): bool|null $sampler - */ - private function createExporter(array &$exported, ?Closure $sampler = null): Exporter - { - return new class ($exported, $sampler) implements Exporter { - /** @var array */ - private array $exported; - - private readonly Closure $sampler; - - /** @param array $exported */ - public function __construct(array &$exported, ?Closure $sampler) - { - $this->exported = &$exported; - $this->sampler = $sampler ?? static fn(Span $span): bool => true; - } - - public function sample(Span $span): bool - { - return ($this->sampler)($span); - } - - public function export(Span $span): void - { - $this->exported[] = $span; - } - }; - } -} diff --git a/packages/span/tests/Storage/AutoTest.php b/packages/span/tests/Storage/AutoTest.php deleted file mode 100644 index 97093fe13..000000000 --- a/packages/span/tests/Storage/AutoTest.php +++ /dev/null @@ -1,63 +0,0 @@ -assertNull($storage->get()); - } - - public function testSetAndGet(): void - { - $storage = new Auto(); - $span = new Span(); - - $storage->set($span); - - $this->assertSame($span, $storage->get()); - } - - public function testSetNullClearsSpan(): void - { - $storage = new Auto(); - $span = new Span(); - - $storage->set($span); - $storage->set(null); - - $this->assertNull($storage->get()); - } - - public function testSetOverwritesPreviousSpan(): void - { - $storage = new Auto(); - $span1 = new Span(); - $span2 = new Span(); - - $storage->set($span1); - $storage->set($span2); - - $this->assertSame($span2, $storage->get()); - } - - public function testUsesMemoryStorageOutsideCoroutine(): void - { - $storage = new Auto(); - $span = new Span(); - - // Outside of a Swoole coroutine, should use Memory storage - $storage->set($span); - - $this->assertSame($span, $storage->get()); - } -} diff --git a/packages/span/tests/Storage/CoroutineTest.php b/packages/span/tests/Storage/CoroutineTest.php deleted file mode 100644 index a450ded4d..000000000 --- a/packages/span/tests/Storage/CoroutineTest.php +++ /dev/null @@ -1,43 +0,0 @@ -markTestSkipped('Swoole extension not available'); - } - } - - public function testGetReturnsNullOutsideCoroutine(): void - { - $storage = new Coroutine(); - - $this->assertNull($storage->get()); - } - - public function testSetDoesNothingOutsideCoroutine(): void - { - $storage = new Coroutine(); - $span = new Span(); - - $storage->set($span); - - $this->assertNull($storage->get()); - } - - public function testSetNullDoesNothingOutsideCoroutine(): void - { - $storage = new Coroutine(); - - $storage->set(null); - - $this->assertNull($storage->get()); - } -} diff --git a/packages/span/tests/Storage/MemoryTest.php b/packages/span/tests/Storage/MemoryTest.php deleted file mode 100644 index 7410b4813..000000000 --- a/packages/span/tests/Storage/MemoryTest.php +++ /dev/null @@ -1,62 +0,0 @@ -assertNull($storage->get()); - } - - public function testSetAndGet(): void - { - $storage = new Memory(); - $span = new Span(); - - $storage->set($span); - - $this->assertSame($span, $storage->get()); - } - - public function testSetNullClearsSpan(): void - { - $storage = new Memory(); - $span = new Span(); - - $storage->set($span); - $storage->set(null); - - $this->assertNull($storage->get()); - } - - public function testSetOverwritesPreviousSpan(): void - { - $storage = new Memory(); - $span1 = new Span(); - $span2 = new Span(); - - $storage->set($span1); - $storage->set($span2); - - $this->assertSame($span2, $storage->get()); - } - - public function testMultipleInstancesAreIndependent(): void - { - $storage1 = new Memory(); - $storage2 = new Memory(); - $span = new Span(); - - $storage1->set($span); - - $this->assertSame($span, $storage1->get()); - $this->assertNull($storage2->get()); - } -}