From 0d296aaa0e3dc3f87deafd5e9b57d2e0c5a680b0 Mon Sep 17 00:00:00 2001 From: Hans van Luttikhuizen-Ross Date: Sun, 4 Oct 2026 12:20:47 +0200 Subject: [PATCH 1/2] Add missing @throws annotations Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011np691Rnida5v4u3wsaKby --- src/Http/Response.php | 7 +++++++ src/Traits/Connector/SendsRequests.php | 4 ++++ src/Traits/Request/HasConnector.php | 3 +++ 3 files changed, 14 insertions(+) diff --git a/src/Http/Response.php b/src/Http/Response.php index 606cfbdd..48e2b43e 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -220,6 +220,7 @@ public function getSenderException(): ?Throwable * * @param array-key|null $key * @return ($key is null ? array : mixed) + * @throws \JsonException */ public function json(string|int|null $key = null, mixed $default = null): mixed { @@ -241,6 +242,7 @@ public function json(string|int|null $key = null, mixed $default = null): mixed * * @param array-key|null $key * @return ($key is null ? array : mixed) + * @throws \JsonException */ public function array(int|string|null $key = null, mixed $default = null): mixed { @@ -249,6 +251,8 @@ public function array(int|string|null $key = null, mixed $default = null): mixed /** * Get the JSON decoded body of the response as an object or scalar value. + * + * @throws \JsonException */ public function object(string|int|null $key = null, mixed $default = null): mixed { @@ -302,6 +306,7 @@ public function xmlReader(): XmlReader * * @param array-key|null $key * @return \Illuminate\Support\Collection + * @throws \JsonException */ public function collect(string|int|null $key = null): Collection { @@ -337,6 +342,8 @@ public function dto(): mixed /** * Convert the response into a DTO or throw a LogicException if the response failed + * + * @throws \LogicException */ public function dtoOrFail(): mixed { diff --git a/src/Traits/Connector/SendsRequests.php b/src/Traits/Connector/SendsRequests.php index 6f1d3081..b8876558 100644 --- a/src/Traits/Connector/SendsRequests.php +++ b/src/Traits/Connector/SendsRequests.php @@ -26,6 +26,8 @@ trait SendsRequests * Send a request synchronously * * @param callable(\Throwable, \Saloon\Http\Request): (bool)|null $handleRetry + * @throws \Saloon\Exceptions\Request\FatalRequestException + * @throws \Saloon\Exceptions\Request\RequestException */ public function send(Request $request, ?MockClient $mockClient = null, ?callable $handleRetry = null): Response { @@ -163,6 +165,8 @@ public function sendAsync(Request $request, ?MockClient $mockClient = null): Pro * @deprecated This method will be removed in Saloon v4. Please refer to the documentation to see connector or request-based retry functionality. * * @param callable(\Throwable, \Saloon\Http\Request): (bool)|null $handleRetry + * @throws \Saloon\Exceptions\Request\FatalRequestException + * @throws \Saloon\Exceptions\Request\RequestException */ public function sendAndRetry(Request $request, int $tries, int $interval = 0, ?callable $handleRetry = null, bool $throw = true, ?MockClient $mockClient = null, bool $useExponentialBackoff = false): Response { diff --git a/src/Traits/Request/HasConnector.php b/src/Traits/Request/HasConnector.php index 26f8859a..7999a513 100644 --- a/src/Traits/Request/HasConnector.php +++ b/src/Traits/Request/HasConnector.php @@ -64,6 +64,9 @@ public function createPendingRequest(?MockClient $mockClient = null): PendingReq /** * Send a request synchronously + * + * @throws \Saloon\Exceptions\Request\FatalRequestException + * @throws \Saloon\Exceptions\Request\RequestException */ public function send(?MockClient $mockClient = null): Response { From 010774611608c9bf8df6ab80bcf0290e8e06319f Mon Sep 17 00:00:00 2001 From: Hans van Luttikhuizen-Ross Date: Sun, 4 Oct 2026 12:21:04 +0200 Subject: [PATCH 2/2] Type dto() from the request's createDtoFromResponse() in PHPStan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011np691Rnida5v4u3wsaKby --- composer.json | 7 +++ extension.neon | 15 ++++++ phpstan-baseline.neon | 2 +- phpstan.dist.neon | 1 + src/Http/PendingRequest.php | 9 ++++ src/Http/Response.php | 16 ++++++ src/PHPStan/CreateDtoMethodResolver.php | 33 ++++++++++++ src/PHPStan/DtoReturnTypeExtension.php | 41 +++++++++++++++ src/PHPStan/DtoThrowTypeExtension.php | 53 ++++++++++++++++++++ src/Traits/Connector/SendsRequests.php | 8 +++ src/Traits/Request/HasConnector.php | 1 + tests/Fixtures/Requests/TypedDtoRequest.php | 28 +++++++++++ tests/PHPStan/DtoReturnTypeExtensionTest.php | 33 ++++++++++++ tests/PHPStan/DtoThrowTypeExtensionTest.php | 44 ++++++++++++++++ tests/PHPStan/Fixtures/dto-return-types.php | 49 ++++++++++++++++++ tests/PHPStan/Fixtures/dto-throw-types.php | 33 ++++++++++++ tests/PHPStan/phpstan-test.neon | 2 + tests/PHPStan/phpstan-throw-test.neon | 10 ++++ 18 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 extension.neon create mode 100644 src/PHPStan/CreateDtoMethodResolver.php create mode 100644 src/PHPStan/DtoReturnTypeExtension.php create mode 100644 src/PHPStan/DtoThrowTypeExtension.php create mode 100644 tests/Fixtures/Requests/TypedDtoRequest.php create mode 100644 tests/PHPStan/DtoReturnTypeExtensionTest.php create mode 100644 tests/PHPStan/DtoThrowTypeExtensionTest.php create mode 100644 tests/PHPStan/Fixtures/dto-return-types.php create mode 100644 tests/PHPStan/Fixtures/dto-throw-types.php create mode 100644 tests/PHPStan/phpstan-test.neon create mode 100644 tests/PHPStan/phpstan-throw-test.neon diff --git a/composer.json b/composer.json index a1b03123..29bfd077 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,13 @@ }, "sort-packages": true }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, "scripts": { "fix-code": [ "./vendor/bin/php-cs-fixer fix --allow-risky=yes" diff --git a/extension.neon b/extension.neon new file mode 100644 index 00000000..75bf6bd4 --- /dev/null +++ b/extension.neon @@ -0,0 +1,15 @@ +services: + - + class: Saloon\PHPStan\CreateDtoMethodResolver + + - + class: Saloon\PHPStan\DtoReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + + - + class: Saloon\PHPStan\DtoThrowTypeExtension + arguments: + implicitThrows: %exceptions.implicitThrows% + tags: + - phpstan.dynamicMethodThrowTypeExtension diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a37770f6..38addf9a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -25,7 +25,7 @@ parameters: path: src/Http/Connector.php - - message: '#^Parameter \#1 \$response of method Saloon\\Http\\PendingRequest\:\:executeResponsePipeline\(\) expects Saloon\\Http\\Response, GuzzleHttp\\Promise\\PromiseInterface\|Saloon\\Http\\Response given\.$#' + message: '#^Parameter \#1 \$response of method Saloon\\Http\\PendingRequest\\:\:executeResponsePipeline\(\) expects Saloon\\Http\\Response, GuzzleHttp\\Promise\\PromiseInterface\|Saloon\\Http\\Response given\.$#' identifier: argument.type count: 1 path: src/Http/Connector.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 2d8200a1..9a4ebd7e 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -4,6 +4,7 @@ ############################################################################################# includes: +- extension.neon - phpstan-baseline.neon parameters: diff --git a/src/Http/PendingRequest.php b/src/Http/PendingRequest.php index fa749508..8777c3ad 100644 --- a/src/Http/PendingRequest.php +++ b/src/Http/PendingRequest.php @@ -36,6 +36,9 @@ use Saloon\Http\PendingRequest\AuthenticatePendingRequest; use Saloon\Http\OAuth2\GetClientCredentialsTokenBasicAuthRequest; +/** + * @template TRequest of \Saloon\Http\Request = \Saloon\Http\Request + */ class PendingRequest { use AuthenticatesRequests; @@ -52,6 +55,8 @@ class PendingRequest /** * The request used by the instance. + * + * @var TRequest */ protected Request $request; @@ -82,6 +87,8 @@ class PendingRequest /** * Build up the request payload. + * + * @param TRequest $request */ public function __construct(Connector $connector, Request $request, ?MockClient $mockClient = null) { @@ -173,6 +180,8 @@ public function executeFatalPipeline(FatalRequestException $throwable): void /** * Get the request. + * + * @return TRequest */ public function getRequest(): Request { diff --git a/src/Http/Response.php b/src/Http/Response.php index 48e2b43e..52eb6d22 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -27,6 +27,9 @@ use Saloon\Contracts\DataObjects\WithResponse; use Saloon\Contracts\ArrayStore as ArrayStoreContract; +/** + * @template TRequest of \Saloon\Http\Request = \Saloon\Http\Request + */ class Response { use Macroable; @@ -43,6 +46,8 @@ class Response /** * The pending request that has all the request properties + * + * @var \Saloon\Http\PendingRequest */ protected readonly PendingRequest $pendingRequest; @@ -85,6 +90,8 @@ class Response /** * Create a new response instance. + * + * @param \Saloon\Http\PendingRequest $pendingRequest */ public function __construct(ResponseInterface $psrResponse, PendingRequest $pendingRequest, RequestInterface $psrRequest, ?Throwable $senderException = null) { @@ -96,6 +103,11 @@ public function __construct(ResponseInterface $psrResponse, PendingRequest $pend /** * Create a new response instance + * + * @template TSentRequest of \Saloon\Http\Request + * + * @param \Saloon\Http\PendingRequest $pendingRequest + * @return static */ public static function fromPsrResponse(ResponseInterface $psrResponse, PendingRequest $pendingRequest, RequestInterface $psrRequest, ?Throwable $senderException = null): static { @@ -104,6 +116,8 @@ public static function fromPsrResponse(ResponseInterface $psrResponse, PendingRe /** * Get the pending request that created the response. + * + * @return \Saloon\Http\PendingRequest */ public function getPendingRequest(): PendingRequest { @@ -120,6 +134,8 @@ public function getConnector(): Connector /** * Get the original request that created the response. + * + * @return TRequest */ public function getRequest(): Request { diff --git a/src/PHPStan/CreateDtoMethodResolver.php b/src/PHPStan/CreateDtoMethodResolver.php new file mode 100644 index 00000000..92aabdac --- /dev/null +++ b/src/PHPStan/CreateDtoMethodResolver.php @@ -0,0 +1,33 @@ +getName(), ['dto', 'dtoOrFail'], true); + } + + /** + * Resolve createDtoFromResponse() on the request that produced the response. + */ + public function resolve(MethodCall $methodCall, Scope $scope): ?ExtendedMethodReflection + { + $requestType = $scope->getType($methodCall->var)->getTemplateType(Response::class, 'TRequest'); + + if (! $requestType->hasMethod('createDtoFromResponse')->yes()) { + return null; + } + + return $requestType->getMethod('createDtoFromResponse', $scope); + } +} diff --git a/src/PHPStan/DtoReturnTypeExtension.php b/src/PHPStan/DtoReturnTypeExtension.php new file mode 100644 index 00000000..ff44a774 --- /dev/null +++ b/src/PHPStan/DtoReturnTypeExtension.php @@ -0,0 +1,41 @@ +resolver->isDtoMethod($methodReflection); + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $dtoType = $this->resolver->resolve($methodCall, $scope)?->getOnlyVariant()->getReturnType(); + + return $dtoType instanceof MixedType ? null : $dtoType; + } +} diff --git a/src/PHPStan/DtoThrowTypeExtension.php b/src/PHPStan/DtoThrowTypeExtension.php new file mode 100644 index 00000000..12592b89 --- /dev/null +++ b/src/PHPStan/DtoThrowTypeExtension.php @@ -0,0 +1,53 @@ +getDeclaringClass()->is(Response::class) + && $this->resolver->isDtoMethod($methodReflection); + } + + public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $createDtoThrowType = $this->resolver->resolve($methodCall, $scope)?->getThrowType(); + + if ($createDtoThrowType === null) { + return $methodReflection->getThrowType() ?? $this->implicitThrowType(); + } + + return TypeCombinator::union($createDtoThrowType, ...array_filter([$methodReflection->getThrowType()])); + } + + /** + * PHPStan reads a null throw type as "throws nothing", so an undeclared + * createDtoFromResponse() has to fall back to what PHPStan infers on its own. + */ + private function implicitThrowType(): ?Type + { + return $this->implicitThrows ? new ObjectType(Throwable::class) : null; + } +} diff --git a/src/Traits/Connector/SendsRequests.php b/src/Traits/Connector/SendsRequests.php index b8876558..441697d8 100644 --- a/src/Traits/Connector/SendsRequests.php +++ b/src/Traits/Connector/SendsRequests.php @@ -25,7 +25,11 @@ trait SendsRequests /** * Send a request synchronously * + * @template TRequest of \Saloon\Http\Request + * + * @param TRequest $request * @param callable(\Throwable, \Saloon\Http\Request): (bool)|null $handleRetry + * @return \Saloon\Http\Response * @throws \Saloon\Exceptions\Request\FatalRequestException * @throws \Saloon\Exceptions\Request\RequestException */ @@ -164,7 +168,11 @@ public function sendAsync(Request $request, ?MockClient $mockClient = null): Pro * * @deprecated This method will be removed in Saloon v4. Please refer to the documentation to see connector or request-based retry functionality. * + * @template TRequest of \Saloon\Http\Request + * + * @param TRequest $request * @param callable(\Throwable, \Saloon\Http\Request): (bool)|null $handleRetry + * @return \Saloon\Http\Response * @throws \Saloon\Exceptions\Request\FatalRequestException * @throws \Saloon\Exceptions\Request\RequestException */ diff --git a/src/Traits/Request/HasConnector.php b/src/Traits/Request/HasConnector.php index 7999a513..d79794f7 100644 --- a/src/Traits/Request/HasConnector.php +++ b/src/Traits/Request/HasConnector.php @@ -65,6 +65,7 @@ public function createPendingRequest(?MockClient $mockClient = null): PendingReq /** * Send a request synchronously * + * @return \Saloon\Http\Response<$this> * @throws \Saloon\Exceptions\Request\FatalRequestException * @throws \Saloon\Exceptions\Request\RequestException */ diff --git a/tests/Fixtures/Requests/TypedDtoRequest.php b/tests/Fixtures/Requests/TypedDtoRequest.php new file mode 100644 index 00000000..2d9ed15b --- /dev/null +++ b/tests/Fixtures/Requests/TypedDtoRequest.php @@ -0,0 +1,28 @@ + + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-test.neon']; + } + + /** + * @return iterable + */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/Fixtures/dto-return-types.php'); + } + + #[DataProvider('dataFileAsserts')] + public function testReturnTypes(string $assertType, string $file, mixed ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } +} diff --git a/tests/PHPStan/DtoThrowTypeExtensionTest.php b/tests/PHPStan/DtoThrowTypeExtensionTest.php new file mode 100644 index 00000000..aacc9bcf --- /dev/null +++ b/tests/PHPStan/DtoThrowTypeExtensionTest.php @@ -0,0 +1,44 @@ + + */ +final class DtoThrowTypeExtensionTest extends RuleTestCase +{ + /** + * @return array + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-throw-test.neon']; + } + + protected function getRule(): Rule + { + return self::getContainer()->getByType(MissingCheckedExceptionInFunctionThrowsRule::class); + } + + public function testThrowTypePropagation(): void + { + $namespace = 'Saloon\Tests\PHPStan\Fixtures'; + + $this->analyse([__DIR__ . '/Fixtures/dto-throw-types.php'], [ + [ + "Function {$namespace}\dtoWithoutDeclaring() throws checked exception JsonException but it's missing from the PHPDoc @throws tag.", + 14, + ], + [ + "Function {$namespace}\dtoOrFailWithoutDeclaring() throws checked exception JsonException but it's missing from the PHPDoc @throws tag.", + 19, + ], + ]); + } +} diff --git a/tests/PHPStan/Fixtures/dto-return-types.php b/tests/PHPStan/Fixtures/dto-return-types.php new file mode 100644 index 00000000..78fe83b9 --- /dev/null +++ b/tests/PHPStan/Fixtures/dto-return-types.php @@ -0,0 +1,49 @@ +send(new TypedDtoRequest)->dto()); +assertType(User::class, $connector->send(new TypedDtoRequest)->dtoOrFail()); + +// The request type survives a variable between sending and reading. +$response = $connector->send(new TypedDtoRequest); +assertType(User::class, $response->dto()); + +// A solo request sends itself. +assertType('Saloon\Http\Response', (new SoloUserRequest)->send()); + +// Without a typed createDtoFromResponse() or a known request, dto() stays mixed. +assertType('mixed', $connector->send(new UserRequest)->dto()); + +function readUnknown(Response $response): void +{ + assertType('mixed', $response->dto()); +} + +/** + * @param \Saloon\Http\PendingRequest<\Saloon\Tests\Fixtures\Requests\TypedDtoRequest> $pendingRequest + */ +function responseFromPendingRequest(PendingRequest $pendingRequest, ResponseInterface $psrResponse, RequestInterface $psrRequest): void +{ + $response = Response::fromPsrResponse($psrResponse, $pendingRequest, $psrRequest); + + assertType('Saloon\Http\Response', $response); + assertType(TypedDtoRequest::class, $response->getRequest()); + assertType(User::class, $response->withBufferedBody('')->dto()); +} diff --git a/tests/PHPStan/Fixtures/dto-throw-types.php b/tests/PHPStan/Fixtures/dto-throw-types.php new file mode 100644 index 00000000..5c9391dd --- /dev/null +++ b/tests/PHPStan/Fixtures/dto-throw-types.php @@ -0,0 +1,33 @@ +send(new TypedDtoRequest)->dto(); +} + +function dtoOrFailWithoutDeclaring(TestConnector $connector): User +{ + return $connector->send(new TypedDtoRequest)->dtoOrFail(); +} + +/** + * @throws \JsonException + */ +function dtoDeclaring(TestConnector $connector): User +{ + return $connector->send(new TypedDtoRequest)->dto(); +} + +function untypedDto(TestConnector $connector): mixed +{ + return $connector->send(new UserRequest)->dto(); +} diff --git a/tests/PHPStan/phpstan-test.neon b/tests/PHPStan/phpstan-test.neon new file mode 100644 index 00000000..da8c6283 --- /dev/null +++ b/tests/PHPStan/phpstan-test.neon @@ -0,0 +1,2 @@ +includes: + - ../../extension.neon diff --git a/tests/PHPStan/phpstan-throw-test.neon b/tests/PHPStan/phpstan-throw-test.neon new file mode 100644 index 00000000..ba007599 --- /dev/null +++ b/tests/PHPStan/phpstan-throw-test.neon @@ -0,0 +1,10 @@ +includes: + - ../../extension.neon + +parameters: + exceptions: + implicitThrows: false + check: + missingCheckedExceptionInThrows: true + checkedExceptionClasses: + - JsonException