diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e73cc1..84d8fb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,14 @@ jobs: lint: timeout-minutes: 10 name: lint - runs-on: ${{ github.repository == 'stainless-sdks/cas-parser-php' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && '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' @@ -37,13 +37,13 @@ jobs: test: timeout-minutes: 10 name: test - runs-on: ${{ github.repository == 'stainless-sdks/cas-parser-php' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && '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/release-doctor.yml b/.github/workflows/release-doctor.yml index 99587de..f3e0d42 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'CASParser/cas-parser-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 1b77f50..6538ca9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.7.0" + ".": "0.8.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index e26d438..20f0c84 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-e5c0c65637cdf3a6c4360b8193973b73a3d35ad1056ef607c3319ef03e591a55.yml -openapi_spec_hash: 7515d1e5fe3130b9f5411f7aacbc8a64 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser/cas-parser-902b4d5af31c8c6bab33a08d9cea10f3444be221abf735466eb9fd58a14e0a87.yml +openapi_spec_hash: e333f46097f3a3a452cb4d6564a3db67 config_hash: 5509bb7a961ae2e79114b24c381606d4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 841b745..c720566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.8.0 (2026-07-18) + +Full Changelog: [v0.7.0...v0.8.0](https://github.com/CASParser/cas-parser-php/compare/v0.7.0...v0.8.0) + +### Features + +* **api:** api update ([61803b2](https://github.com/CASParser/cas-parser-php/commit/61803b218533dc37365058e8d109cc47253c09de)) +* **api:** api update ([673d298](https://github.com/CASParser/cas-parser-php/commit/673d29896be612e78e60e40283e6ce7bb980f0c3)) +* **stlc:** configurable CI runner and private-production-repo support in workflow templates ([9a46640](https://github.com/CASParser/cas-parser-php/commit/9a46640f85e4f02cf8bacafb197ae0e8f1bc902f)) +* support setting headers via env ([47b835c](https://github.com/CASParser/cas-parser-php/commit/47b835c86b80681f501f029709a8198fb44cdce3)) + + +### Bug Fixes + +* guzzle requires special handling to enable streaming ([7a5def3](https://github.com/CASParser/cas-parser-php/commit/7a5def32e6cff5d527ec67949b5882411f367253)) +* revert enum parsing change that lead to unconditional failure ([34892b3](https://github.com/CASParser/cas-parser-php/commit/34892b325daf92e6c108372a3d20372e6fbd4a61)) + ## 0.7.0 (2026-04-19) Full Changelog: [v0.6.2...v0.7.0](https://github.com/CASParser/cas-parser-php/compare/v0.6.2...v0.7.0) diff --git a/composer.json b/composer.json index 1fee198..6830a8f 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 8267a23..5a4eb37 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,6 +5,7 @@ namespace CasParser; use CasParser\Core\BaseClient; +use CasParser\Core\Implementation\StreamingHttpClient; use CasParser\Core\Util; use CasParser\Services\AccessTokenService; use CasParser\Services\CamsKfintechService; @@ -113,18 +114,36 @@ 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', + 'Accept' => 'application/json', + 'User-Agent' => sprintf('cas-parser/PHP %s', VERSION), + 'X-Stainless-Lang' => 'php', + 'X-Stainless-Package-Version' => '0.7.0', + 'X-Stainless-Arch' => Util::machtype(), + 'X-Stainless-OS' => Util::ostype(), + 'X-Stainless-Runtime' => php_sapi_name(), + 'X-Stainless-Runtime-Version' => phpversion(), + ]; + + $customHeadersEnv = Util::getenv('CAS_PARSER_CUSTOM_HEADERS'); + if (null !== $customHeadersEnv) { + foreach (explode("\n", $customHeadersEnv) as $line) { + $colon = strpos($line, ':'); + if (false !== $colon) { + $headers[trim(substr($line, 0, $colon))] = trim(substr($line, $colon + 1)); + } + } + } + parent::__construct( - headers: [ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - 'User-Agent' => sprintf('cas-parser/PHP %s', VERSION), - 'X-Stainless-Lang' => 'php', - 'X-Stainless-Package-Version' => '0.0.1', - 'X-Stainless-Arch' => Util::machtype(), - 'X-Stainless-OS' => Util::ostype(), - 'X-Stainless-Runtime' => php_sapi_name(), - 'X-Stainless-Runtime-Version' => phpversion(), - ], + headers: $headers, baseUrl: $baseUrl, options: $options ); diff --git a/src/Core/BaseClient.php b/src/Core/BaseClient.php index 50534bf..6f115a5 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/Conversion/EnumOf.php b/src/Core/Conversion/EnumOf.php index 55872cd..611a7df 100644 --- a/src/Core/Conversion/EnumOf.php +++ b/src/Core/Conversion/EnumOf.php @@ -19,12 +19,9 @@ final class EnumOf implements Converter /** * @param list $members - * @param class-string<\BackedEnum>|null $class */ - public function __construct( - private readonly array $members, - private readonly ?string $class = null, - ) { + public function __construct(private readonly array $members) + { $type = 'NULL'; foreach ($this->members as $member) { $type = gettype($member); @@ -36,24 +33,13 @@ public function __construct( public static function fromBackedEnum(string $enum): self { // @phpstan-ignore-next-line argument.type - return self::$cache[$enum] ??= new self( - array_column($enum::cases(), column_key: 'value'), - class: $enum, - ); + return self::$cache[$enum] ??= new self(array_column($enum::cases(), column_key: 'value')); } public function coerce(mixed $value, CoerceState $state): mixed { $this->tally($value, state: $state); - if ($value instanceof \BackedEnum) { - return $value; - } - - if (null !== $this->class && (is_int($value) || is_string($value))) { - return ($this->class)::tryFrom($value) ?? $value; - } - return $value; } diff --git a/src/Core/FileParam.php b/src/Core/FileParam.php index 992b5f0..ba610ff 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..23c346a --- /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 d7fbe57..b3db605 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/InboundEmail/InboundEmailGetResponse.php b/src/InboundEmail/InboundEmailGetResponse.php index f692457..c5ed54c 100644 --- a/src/InboundEmail/InboundEmailGetResponse.php +++ b/src/InboundEmail/InboundEmailGetResponse.php @@ -39,14 +39,15 @@ final class InboundEmailGetResponse implements BaseModel public ?array $allowedSources; /** - * Webhook URL for email notifications. `null` means files are only - * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). + * Webhook URL for email notifications. If set, we POST each parsed + * email here. If omitted, files are only retrievable via + * `GET /v4/inbound-email/{id}/files`. */ - #[Optional('callback_url', nullable: true)] + #[Optional('callback_url')] public ?string $callbackURL; /** - * When the mailbox was created. + * When the inbound email was created. */ #[Optional('created_at')] public ?\DateTimeInterface $createdAt; @@ -78,7 +79,7 @@ final class InboundEmailGetResponse implements BaseModel public ?string $reference; /** - * Current mailbox status. + * Current inbound email lifecycle status. * * @var value-of|null $status */ @@ -86,7 +87,7 @@ final class InboundEmailGetResponse implements BaseModel public ?string $status; /** - * When the mailbox was last updated. + * When the inbound email was last updated. */ #[Optional('updated_at')] public ?\DateTimeInterface $updatedAt; @@ -145,10 +146,11 @@ public function withAllowedSources(array $allowedSources): self } /** - * Webhook URL for email notifications. `null` means files are only - * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). + * Webhook URL for email notifications. If set, we POST each parsed + * email here. If omitted, files are only retrievable via + * `GET /v4/inbound-email/{id}/files`. */ - public function withCallbackURL(?string $callbackURL): self + public function withCallbackURL(string $callbackURL): self { $self = clone $this; $self['callbackURL'] = $callbackURL; @@ -157,7 +159,7 @@ public function withCallbackURL(?string $callbackURL): self } /** - * When the mailbox was created. + * When the inbound email was created. */ public function withCreatedAt(\DateTimeInterface $createdAt): self { @@ -214,7 +216,7 @@ public function withReference(?string $reference): self } /** - * Current mailbox status. + * Current inbound email lifecycle status. * * @param Status|value-of $status */ @@ -227,7 +229,7 @@ public function withStatus(Status|string $status): self } /** - * When the mailbox was last updated. + * When the inbound email was last updated. */ public function withUpdatedAt(\DateTimeInterface $updatedAt): self { diff --git a/src/InboundEmail/InboundEmailGetResponse/Status.php b/src/InboundEmail/InboundEmailGetResponse/Status.php index 4ba7c1a..a8fae74 100644 --- a/src/InboundEmail/InboundEmailGetResponse/Status.php +++ b/src/InboundEmail/InboundEmailGetResponse/Status.php @@ -5,7 +5,7 @@ namespace CasParser\InboundEmail\InboundEmailGetResponse; /** - * Current mailbox status. + * Current inbound email lifecycle status. */ enum Status: string { diff --git a/src/InboundEmail/InboundEmailListParams.php b/src/InboundEmail/InboundEmailListParams.php index 99dde7f..ceb05f7 100644 --- a/src/InboundEmail/InboundEmailListParams.php +++ b/src/InboundEmail/InboundEmailListParams.php @@ -11,8 +11,8 @@ use CasParser\InboundEmail\InboundEmailListParams\Status; /** - * List all mailboxes associated with your API key. - * Returns active and inactive mailboxes (deleted mailboxes are excluded). + * List all inbound emails associated with your API key. + * Returns active and paused inbound emails (deleted ones are excluded). * * @see CasParser\Services\InboundEmailService::list() * diff --git a/src/InboundEmail/InboundEmailListResponse/InboundEmail.php b/src/InboundEmail/InboundEmailListResponse/InboundEmail.php index 6ee9453..d4e32a8 100644 --- a/src/InboundEmail/InboundEmailListResponse/InboundEmail.php +++ b/src/InboundEmail/InboundEmailListResponse/InboundEmail.php @@ -39,14 +39,15 @@ final class InboundEmail implements BaseModel public ?array $allowedSources; /** - * Webhook URL for email notifications. `null` means files are only - * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). + * Webhook URL for email notifications. If set, we POST each parsed + * email here. If omitted, files are only retrievable via + * `GET /v4/inbound-email/{id}/files`. */ - #[Optional('callback_url', nullable: true)] + #[Optional('callback_url')] public ?string $callbackURL; /** - * When the mailbox was created. + * When the inbound email was created. */ #[Optional('created_at')] public ?\DateTimeInterface $createdAt; @@ -78,7 +79,7 @@ final class InboundEmail implements BaseModel public ?string $reference; /** - * Current mailbox status. + * Current inbound email lifecycle status. * * @var value-of|null $status */ @@ -86,7 +87,7 @@ final class InboundEmail implements BaseModel public ?string $status; /** - * When the mailbox was last updated. + * When the inbound email was last updated. */ #[Optional('updated_at')] public ?\DateTimeInterface $updatedAt; @@ -145,10 +146,11 @@ public function withAllowedSources(array $allowedSources): self } /** - * Webhook URL for email notifications. `null` means files are only - * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). + * Webhook URL for email notifications. If set, we POST each parsed + * email here. If omitted, files are only retrievable via + * `GET /v4/inbound-email/{id}/files`. */ - public function withCallbackURL(?string $callbackURL): self + public function withCallbackURL(string $callbackURL): self { $self = clone $this; $self['callbackURL'] = $callbackURL; @@ -157,7 +159,7 @@ public function withCallbackURL(?string $callbackURL): self } /** - * When the mailbox was created. + * When the inbound email was created. */ public function withCreatedAt(\DateTimeInterface $createdAt): self { @@ -214,7 +216,7 @@ public function withReference(?string $reference): self } /** - * Current mailbox status. + * Current inbound email lifecycle status. * * @param Status|value-of $status */ @@ -227,7 +229,7 @@ public function withStatus(Status|string $status): self } /** - * When the mailbox was last updated. + * When the inbound email was last updated. */ public function withUpdatedAt(\DateTimeInterface $updatedAt): self { diff --git a/src/InboundEmail/InboundEmailListResponse/InboundEmail/Status.php b/src/InboundEmail/InboundEmailListResponse/InboundEmail/Status.php index f884841..c82fed3 100644 --- a/src/InboundEmail/InboundEmailListResponse/InboundEmail/Status.php +++ b/src/InboundEmail/InboundEmailListResponse/InboundEmail/Status.php @@ -5,7 +5,7 @@ namespace CasParser\InboundEmail\InboundEmailListResponse\InboundEmail; /** - * Current mailbox status. + * Current inbound email lifecycle status. */ enum Status: string { diff --git a/src/InboundEmail/InboundEmailNewResponse.php b/src/InboundEmail/InboundEmailNewResponse.php index f5fe80f..86d25ba 100644 --- a/src/InboundEmail/InboundEmailNewResponse.php +++ b/src/InboundEmail/InboundEmailNewResponse.php @@ -39,14 +39,15 @@ final class InboundEmailNewResponse implements BaseModel public ?array $allowedSources; /** - * Webhook URL for email notifications. `null` means files are only - * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). + * Webhook URL for email notifications. If set, we POST each parsed + * email here. If omitted, files are only retrievable via + * `GET /v4/inbound-email/{id}/files`. */ - #[Optional('callback_url', nullable: true)] + #[Optional('callback_url')] public ?string $callbackURL; /** - * When the mailbox was created. + * When the inbound email was created. */ #[Optional('created_at')] public ?\DateTimeInterface $createdAt; @@ -78,7 +79,7 @@ final class InboundEmailNewResponse implements BaseModel public ?string $reference; /** - * Current mailbox status. + * Current inbound email lifecycle status. * * @var value-of|null $status */ @@ -86,7 +87,7 @@ final class InboundEmailNewResponse implements BaseModel public ?string $status; /** - * When the mailbox was last updated. + * When the inbound email was last updated. */ #[Optional('updated_at')] public ?\DateTimeInterface $updatedAt; @@ -145,10 +146,11 @@ public function withAllowedSources(array $allowedSources): self } /** - * Webhook URL for email notifications. `null` means files are only - * retrievable via `GET /v4/inbound-email/{id}/files` (pull delivery). + * Webhook URL for email notifications. If set, we POST each parsed + * email here. If omitted, files are only retrievable via + * `GET /v4/inbound-email/{id}/files`. */ - public function withCallbackURL(?string $callbackURL): self + public function withCallbackURL(string $callbackURL): self { $self = clone $this; $self['callbackURL'] = $callbackURL; @@ -157,7 +159,7 @@ public function withCallbackURL(?string $callbackURL): self } /** - * When the mailbox was created. + * When the inbound email was created. */ public function withCreatedAt(\DateTimeInterface $createdAt): self { @@ -214,7 +216,7 @@ public function withReference(?string $reference): self } /** - * Current mailbox status. + * Current inbound email lifecycle status. * * @param Status|value-of $status */ @@ -227,7 +229,7 @@ public function withStatus(Status|string $status): self } /** - * When the mailbox was last updated. + * When the inbound email was last updated. */ public function withUpdatedAt(\DateTimeInterface $updatedAt): self { diff --git a/src/InboundEmail/InboundEmailNewResponse/Status.php b/src/InboundEmail/InboundEmailNewResponse/Status.php index ee4c67a..2371655 100644 --- a/src/InboundEmail/InboundEmailNewResponse/Status.php +++ b/src/InboundEmail/InboundEmailNewResponse/Status.php @@ -5,7 +5,7 @@ namespace CasParser\InboundEmail\InboundEmailNewResponse; /** - * Current mailbox status. + * Current inbound email lifecycle status. */ enum Status: string { diff --git a/src/Inbox/InboxListCasFilesResponse/File.php b/src/Inbox/InboxListCasFilesResponse/File.php index 99c15a9..e30938d 100644 --- a/src/Inbox/InboxListCasFilesResponse/File.php +++ b/src/Inbox/InboxListCasFilesResponse/File.php @@ -38,7 +38,10 @@ final class File implements BaseModel public ?string $casType; /** - * URL expiration time in seconds (default 86400 = 24 hours). + * URL expiration time in seconds. Defaults vary by source: + * - Gmail Inbox Import: 86400 (24h) + * - Inbound Email with `callback_url` set: 172800 (48h) + * - Inbound Email without `callback_url`: aligned with the session TTL (~30 min) */ #[Optional('expires_in')] public ?int $expiresIn; @@ -137,7 +140,10 @@ public function withCasType(CasType|string $casType): self } /** - * URL expiration time in seconds (default 86400 = 24 hours). + * URL expiration time in seconds. Defaults vary by source: + * - Gmail Inbox Import: 86400 (24h) + * - Inbound Email with `callback_url` set: 172800 (48h) + * - Inbound Email without `callback_url`: aligned with the session TTL (~30 min) */ public function withExpiresIn(int $expiresIn): self { diff --git a/src/RequestOptions.php b/src/RequestOptions.php index 7ae6885..ab11b39 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/Services/InboundEmailRawService.php b/src/Services/InboundEmailRawService.php index f42c0ee..561aa34 100644 --- a/src/Services/InboundEmailRawService.php +++ b/src/Services/InboundEmailRawService.php @@ -97,7 +97,7 @@ public function create( /** * @api * - * Retrieve details of a specific mailbox including statistics. + * Retrieve details of a specific inbound email including statistics. * * @param string $inboundEmailID Inbound Email ID * @param RequestOpts|null $requestOptions @@ -122,8 +122,8 @@ public function retrieve( /** * @api * - * List all mailboxes associated with your API key. - * Returns active and inactive mailboxes (deleted mailboxes are excluded). + * List all inbound emails associated with your API key. + * Returns active and paused inbound emails (deleted ones are excluded). * * @param array{ * limit?: int, offset?: int, status?: Status|value-of diff --git a/src/Services/InboundEmailService.php b/src/Services/InboundEmailService.php index 3506afb..4f838b2 100644 --- a/src/Services/InboundEmailService.php +++ b/src/Services/InboundEmailService.php @@ -114,7 +114,7 @@ public function create( /** * @api * - * Retrieve details of a specific mailbox including statistics. + * Retrieve details of a specific inbound email including statistics. * * @param string $inboundEmailID Inbound Email ID * @param RequestOpts|null $requestOptions @@ -134,8 +134,8 @@ public function retrieve( /** * @api * - * List all mailboxes associated with your API key. - * Returns active and inactive mailboxes (deleted mailboxes are excluded). + * List all inbound emails associated with your API key. + * Returns active and paused inbound emails (deleted ones are excluded). * * @param int $limit Maximum number of inbound emails to return * @param int $offset Pagination offset diff --git a/src/Version.php b/src/Version.php index 57860bd..1c12b5e 100644 --- a/src/Version.php +++ b/src/Version.php @@ -5,5 +5,5 @@ namespace CasParser; // x-release-please-start-version -const VERSION = '0.7.0'; +const VERSION = '0.8.0'; // x-release-please-end diff --git a/tests/Core/ModelTest.php b/tests/Core/ModelTest.php index c551866..033b9d1 100644 --- a/tests/Core/ModelTest.php +++ b/tests/Core/ModelTest.php @@ -47,30 +47,6 @@ public function __construct( } } -enum TicketPriority: string -{ - case Low = 'low'; - case High = 'high'; -} - -class Ticket implements BaseModel -{ - /** @use SdkModel> */ - use SdkModel; - - #[Required(enum: TicketPriority::class)] - public TicketPriority $priority; - - /** @var list */ - #[Required(list: TicketPriority::class)] - public array $labels; - - public function __construct() - { - $this->initialize(); - } -} - /** * @internal * @@ -165,42 +141,4 @@ public function testSerializeModelWithExplicitNull(): void json_encode($model) ); } - - #[Test] - public function testScalarEnumCoercesToInstance(): void - { - $model = Ticket::fromArray(['priority' => 'low', 'labels' => []]); - $this->assertSame(TicketPriority::Low, $model->priority); - } - - #[Test] - public function testListOfEnumCoercesElementsToInstances(): void - { - $model = Ticket::fromArray(['priority' => 'low', 'labels' => ['low', 'high']]); - $this->assertCount(2, $model->labels); - $this->assertSame(TicketPriority::Low, $model->labels[0]); - $this->assertSame(TicketPriority::High, $model->labels[1]); - } - - #[Test] - public function testEnumInstancePassesThrough(): void - { - $model = Ticket::fromArray(['priority' => TicketPriority::High, 'labels' => []]); - $this->assertSame(TicketPriority::High, $model->priority); - } - - #[Test] - public function testInvalidEnumScalarFallsBackToData(): void - { - $model = Ticket::fromArray(['priority' => 'urgent', 'labels' => []]); - $this->assertSame('urgent', $model['priority']); - } - - #[Test] - public function testEnumWireFormatStableAcrossConstruction(): void - { - $fromScalar = Ticket::fromArray(['priority' => 'low', 'labels' => ['high']]); - $fromInstance = Ticket::fromArray(['priority' => TicketPriority::Low, 'labels' => [TicketPriority::High]]); - $this->assertSame(json_encode($fromScalar), json_encode($fromInstance)); - } } diff --git a/tests/Core/StreamingTransportTest.php b/tests/Core/StreamingTransportTest.php new file mode 100644 index 0000000..3ff0d4f --- /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('')) + ; + } +}