diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5721b93..d4a9bcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up PHP - uses: 'shivammathur/setup-php@v2' + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' @@ -40,10 +40,10 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/stagehand-php' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up PHP - uses: 'shivammathur/setup-php@v2' + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' diff --git a/.github/workflows/publish-packagist.yml b/.github/workflows/publish-packagist.yml index 6c6f68e..dc56960 100644 --- a/.github/workflows/publish-packagist.yml +++ b/.github/workflows/publish-packagist.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Publish to Packagist run: |- diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 1358383..ebc8b13 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'browserbase/stagehand-php' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d11c8fc..eba8a04 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.20.0" + ".": "3.21.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 391cde3..0339c57 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/stagehand-6f6bfb81d092f30a5e2005328c97d61b9ea36132bb19e9e79e55294b9534ce20.yml -openapi_spec_hash: f3fc1e3688a38dc2c28f7178f7d534e5 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/stagehand-eae8400fade7b2c8329c4148f56de92e147c34c0feecb420c015aab6544a9acc.yml +openapi_spec_hash: 0a9eff1ac1d464e89cbd9db64709b08a config_hash: 1fb12ae9b478488bc1e56bfbdc210b01 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba66f7..0716adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 3.21.0 (2026-05-13) + +Full Changelog: [v3.20.0...v3.21.0](https://github.com/browserbase/stagehand-php/compare/v3.20.0...v3.21.0) + +### Features + +* [feat]: add `ignoreSelectors` to `observe()` ([872590f](https://github.com/browserbase/stagehand-php/commit/872590fe228fd2f0ce70d7cbefdddbf3c22fe147)) + + +### Bug Fixes + +* guzzle requires special handling to enable streaming ([9d1c43a](https://github.com/browserbase/stagehand-php/commit/9d1c43a1435dc0ba0f66094ffaf4c3e6d463b17a)) + ## 3.20.0 (2026-05-06) Full Changelog: [v3.19.3...v3.20.0](https://github.com/browserbase/stagehand-php/compare/v3.19.3...v3.20.0) diff --git a/README.md b/README.md index 574163f..dd7e623 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The REST API documentation can be found on [docs.stagehand.dev](https://docs.sta ``` -composer require "browserbase/stagehand 3.20.0" +composer require "browserbase/stagehand 3.21.0" ``` @@ -258,6 +258,15 @@ foreach ($stream as $response) { } ``` +Streaming requests are dispatched through a separate `streamingTransporter` PSR-18 HTTP client. When unset, the SDK uses the configured `transporter`. +Some PSR-18 HTTP clients will by default try to read the entire response, so you may need to specify a streaming capable implementation. + +```php +$client = new Stagehand\Client( + requestOptions: Stagehand\RequestOptions::with(streamingTransporter: $myStreamingClient), +); +``` + ### Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `Stagehand\Core\Exceptions\APIException` will be thrown: diff --git a/composer.json b/composer.json index 5d324e7..1d6aac5 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3", + "guzzlehttp/guzzle": "^7", "nyholm/psr7": "^1", "pestphp/pest": "^3", "php-http/mock-client": "^1", diff --git a/composer.lock b/composer.lock index 7a5e63d..b0e2283 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ffa287ea8babf60e021f37e62c6c207a", + "content-hash": "bbae64cb4d21c987158bc3723eda4226", "packages": [ { "name": "php-http/discovery", @@ -968,6 +968,332 @@ ], "time": "2026-01-08T21:57:37+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-03-10T16:41:02+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -3250,6 +3576,50 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "react/cache", "version": "v1.2.0", @@ -6680,5 +7050,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Client.php b/src/Client.php index c571bb6..5a3ebb0 100644 --- a/src/Client.php +++ b/src/Client.php @@ -7,6 +7,7 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Stagehand\Core\BaseClient; +use Stagehand\Core\Implementation\StreamingHttpClient; use Stagehand\Core\Util; use Stagehand\Services\SessionsService; @@ -63,6 +64,11 @@ public function __construct( $requestOptions, ); + if (is_null($options->streamingTransporter)) { + assert(!is_null($options->transporter)); + $options->streamingTransporter = new StreamingHttpClient($options->transporter); + } + /** @var array $headers */ $headers = [ 'Content-Type' => 'application/json', diff --git a/src/Core/BaseClient.php b/src/Core/BaseClient.php index 98f540d..29e21ec 100644 --- a/src/Core/BaseClient.php +++ b/src/Core/BaseClient.php @@ -241,11 +241,15 @@ protected function sendRequest( $req = $req->withHeader('X-Stainless-Retry-Count', strval($retryCount)); $req = Util::withSetBody($opts->streamFactory, req: $req, body: $data); + $transporter = Util::isStreamingRequest($req) + ? ($opts->streamingTransporter ?? $opts->transporter) + : $opts->transporter; + $rsp = null; $err = null; try { - $rsp = $opts->transporter->sendRequest($req); + $rsp = $transporter->sendRequest($req); } catch (ClientExceptionInterface $e) { $err = $e; } diff --git a/src/Core/FileParam.php b/src/Core/FileParam.php index bc52309..5924990 100644 --- a/src/Core/FileParam.php +++ b/src/Core/FileParam.php @@ -41,7 +41,7 @@ public static function fromResource(mixed $resource, ?string $filename = null, s throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource)); } - if (null === $filename) { + if (is_null($filename)) { $meta = stream_get_meta_data($resource); $filename = basename($meta['uri'] ?? 'upload'); } diff --git a/src/Core/Implementation/StreamingHttpClient.php b/src/Core/Implementation/StreamingHttpClient.php new file mode 100644 index 0000000..fcc7983 --- /dev/null +++ b/src/Core/Implementation/StreamingHttpClient.php @@ -0,0 +1,29 @@ +inner, '\GuzzleHttp\Client')) { + return $this->inner->send($request, ['stream' => true]); + } + + return $this->inner->sendRequest($request); + } +} diff --git a/src/Core/Util.php b/src/Core/Util.php index 19a90c3..1285cc0 100644 --- a/src/Core/Util.php +++ b/src/Core/Util.php @@ -25,6 +25,8 @@ final class Util public const JSONL_CONTENT_TYPE = '/^application\/(:?x-(?:n|l)djson)|(:?(?:x-)?jsonl)/'; + public const STREAMING_CONTENT_TYPE = ['/^text\/event-stream/', self::JSONL_CONTENT_TYPE]; + public static function getenv(string $key): ?string { if (array_key_exists($key, array: $_ENV)) { @@ -217,6 +219,16 @@ public static function joinUri( return $base->withQuery($qs); } + public static function isStreamingRequest(RequestInterface $request): bool + { + $accept = $request->getHeaderLine('Accept'); + + return !empty(array_filter( + self::STREAMING_CONTENT_TYPE, + static fn (string $pattern) => (bool) preg_match($pattern, subject: $accept), + )); + } + /** * @param array|null> $headers */ diff --git a/src/RequestOptions.php b/src/RequestOptions.php index 3c8fbd0..4aea98b 100644 --- a/src/RequestOptions.php +++ b/src/RequestOptions.php @@ -23,6 +23,7 @@ * extraQueryParams?: array|null, * extraBodyParams?: mixed, * transporter?: ClientInterface|null, + * streamingTransporter?: ClientInterface|null, * uriFactory?: UriFactoryInterface|null, * streamFactory?: StreamFactoryInterface|null, * requestFactory?: RequestFactoryInterface|null, @@ -60,6 +61,9 @@ final class RequestOptions implements BaseModel #[Optional] public ?ClientInterface $transporter; + #[Optional] + public ?ClientInterface $streamingTransporter; + #[Optional] public ?UriFactoryInterface $uriFactory; @@ -98,6 +102,7 @@ public static function with( ?array $extraQueryParams = null, mixed $extraBodyParams = null, ?ClientInterface $transporter = null, + ?ClientInterface $streamingTransporter = null, ?UriFactoryInterface $uriFactory = null, ?StreamFactoryInterface $streamFactory = null, ?RequestFactoryInterface $requestFactory = null, @@ -114,6 +119,9 @@ public static function with( null !== $extraQueryParams && $self->extraQueryParams = $extraQueryParams; null !== $extraBodyParams && $self->extraBodyParams = $extraBodyParams; null !== $transporter && $self->transporter = $transporter; + null !== $streamingTransporter && $self + ->streamingTransporter = $streamingTransporter + ; null !== $uriFactory && $self->uriFactory = $uriFactory; null !== $streamFactory && $self->streamFactory = $streamFactory; null !== $requestFactory && $self->requestFactory = $requestFactory; @@ -191,6 +199,15 @@ public function withTransporter(ClientInterface $transporter): self return $self; } + public function withStreamingTransporter( + ClientInterface $streamingTransporter + ): self { + $self = clone $this; + $self->streamingTransporter = $streamingTransporter; + + return $self; + } + public function withUriFactory(UriFactoryInterface $uriFactory): self { $self = clone $this; diff --git a/src/Sessions/SessionObserveParams/Options.php b/src/Sessions/SessionObserveParams/Options.php index 02bc11c..5130e30 100644 --- a/src/Sessions/SessionObserveParams/Options.php +++ b/src/Sessions/SessionObserveParams/Options.php @@ -17,6 +17,7 @@ * @phpstan-import-type VariableShape from \Stagehand\Sessions\SessionObserveParams\Options\Variable * * @phpstan-type OptionsShape = array{ + * ignoreSelectors?: list|null, * model?: ModelShape|null, * selector?: string|null, * timeout?: float|null, @@ -28,6 +29,14 @@ final class Options implements BaseModel /** @use SdkModel */ use SdkModel; + /** + * Selectors for elements and subtrees that should be excluded from observation. + * + * @var list|null $ignoreSelectors + */ + #[Optional(list: 'string')] + public ?array $ignoreSelectors; + /** * Model configuration object or model name string (e.g., 'openai/gpt-5-nano'). * @@ -66,10 +75,12 @@ public function __construct() * * You must use named parameters to construct any parameters with a default value. * + * @param list|null $ignoreSelectors * @param ModelShape|null $model * @param array|null $variables */ public static function with( + ?array $ignoreSelectors = null, string|ModelConfig|array|null $model = null, ?string $selector = null, ?float $timeout = null, @@ -77,6 +88,7 @@ public static function with( ): self { $self = new self; + null !== $ignoreSelectors && $self['ignoreSelectors'] = $ignoreSelectors; null !== $model && $self['model'] = $model; null !== $selector && $self['selector'] = $selector; null !== $timeout && $self['timeout'] = $timeout; @@ -85,6 +97,19 @@ public static function with( return $self; } + /** + * Selectors for elements and subtrees that should be excluded from observation. + * + * @param list $ignoreSelectors + */ + public function withIgnoreSelectors(array $ignoreSelectors): self + { + $self = clone $this; + $self['ignoreSelectors'] = $ignoreSelectors; + + return $self; + } + /** * Model configuration object or model name string (e.g., 'openai/gpt-5-nano'). * diff --git a/src/Version.php b/src/Version.php index 7c36484..b10e37c 100644 --- a/src/Version.php +++ b/src/Version.php @@ -5,5 +5,5 @@ namespace Stagehand; // x-release-please-start-version -const VERSION = '3.20.0'; +const VERSION = '3.21.0'; // x-release-please-end diff --git a/tests/Core/StreamingTransportTest.php b/tests/Core/StreamingTransportTest.php new file mode 100644 index 0000000..0f3e046 --- /dev/null +++ b/tests/Core/StreamingTransportTest.php @@ -0,0 +1,122 @@ + true, + 'application/x-ndjson' => true, + 'application/x-ldjson' => true, + 'application/jsonl' => true, + 'application/x-jsonl' => true, + 'text/event-stream; charset=utf-8' => true, + 'application/json' => false, + 'text/plain' => false, + '' => false, + ]; + + foreach ($cases as $accept => $expected) { + $req = $factory->createRequest('GET', 'http://localhost'); + if ('' !== $accept) { + $req = $req->withHeader('Accept', $accept); + } + $this->assertSame( + $expected, + Util::isStreamingRequest($req), + "Accept: '{$accept}'", + ); + } + } + + #[Test] + public function testRoutesNonStreamingRequestToTransporter(): void + { + [$client, $plain, $streaming] = $this->buildClient(); + + $client->request('GET', '/'); + + $this->assertCount(1, $plain->getRequests()); + $this->assertCount(0, $streaming->getRequests()); + } + + #[Test] + public function testRoutesStreamingRequestToStreamingTransporter(): void + { + [$client, $plain, $streaming] = $this->buildClient(); + + $client->request('GET', '/', headers: ['Accept' => 'text/event-stream']); + + $this->assertCount(0, $plain->getRequests()); + $this->assertCount(1, $streaming->getRequests()); + + $sent = $streaming->getRequests()[0]; + $this->assertStringContainsString('text/event-stream', $sent->getHeaderLine('Accept')); + } + + /** + * @return array{BaseClient, MockClient, MockClient} + */ + private function buildClient(): array + { + $plain = new MockClient; + $plain->setDefaultResponse($this->jsonResponse()); + + $streaming = new MockClient; + $streaming->setDefaultResponse($this->sseResponse()); + + $options = RequestOptions::with( + transporter: $plain, + streamingTransporter: $streaming, + uriFactory: Psr17FactoryDiscovery::findUriFactory(), + requestFactory: Psr17FactoryDiscovery::findRequestFactory(), + streamFactory: Psr17FactoryDiscovery::findStreamFactory(), + ); + + $client = new class(headers: [], baseUrl: 'http://localhost', options: $options) extends BaseClient {}; + + return [$client, $plain, $streaming]; + } + + private function jsonResponse(): ResponseInterface + { + $responseFactory = Psr17FactoryDiscovery::findResponseFactory(); + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + return $responseFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($streamFactory->createStream('{}')) + ; + } + + private function sseResponse(): ResponseInterface + { + $responseFactory = Psr17FactoryDiscovery::findResponseFactory(); + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + return $responseFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withBody($streamFactory->createStream('')) + ; + } +}