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()); - } -}