diff --git a/src/Contracts/DataObjects/IntoObject.php b/src/Contracts/DataObjects/IntoObject.php new file mode 100644 index 00000000..47b14ce6 --- /dev/null +++ b/src/Contracts/DataObjects/IntoObject.php @@ -0,0 +1,15 @@ + + */ + public static function fromResponse(Response $response): array; +} diff --git a/src/Http/Faking/Fixture.php b/src/Http/Faking/Fixture.php index cbbf11ad..bfe0e1e7 100644 --- a/src/Http/Faking/Fixture.php +++ b/src/Http/Faking/Fixture.php @@ -4,8 +4,11 @@ namespace Saloon\Http\Faking; +use Closure; +use JsonException; use Saloon\MockConfig; use Saloon\Helpers\Storage; +use const JSON_THROW_ON_ERROR; use Saloon\Helpers\ArrayHelpers; use Saloon\Data\RecordedResponse; use Saloon\Helpers\FixtureHelper; @@ -40,7 +43,7 @@ class Fixture /** * Closure to modify the returned data with. */ - protected ?\Closure $through = null; + protected ?Closure $through = null; /** * Constructor @@ -66,7 +69,7 @@ public function merge(array $merge = []): static /** * Specify a closure to modify the mock response data with. */ - public function through(\Closure $through): static + public function through(Closure $through): static { $this->through = $through; @@ -91,7 +94,7 @@ public function getMockResponse(): ?MockResponse // First, we get the body as an array. If we're dealing with // a `StringBodyRepository`, we have to encode it first. if (! is_array($body = $response->body()->all())) { - $body = json_decode($body ?: '[]', associative: true, flags: \JSON_THROW_ON_ERROR); + $body = json_decode($body ?: '[]', associative: true, flags: JSON_THROW_ON_ERROR); } // We can then merge the data in the body usingthrough @@ -190,7 +193,7 @@ protected function swapSensitiveHeaders(RecordedResponse $recordedResponse): Rec /** * Swap any sensitive JSON data * - * @throws \JsonException + * @throws JsonException */ protected function swapSensitiveJson(RecordedResponse $recordedResponse): RecordedResponse { diff --git a/src/Http/Response.php b/src/Http/Response.php index 06c878bd..60b15241 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -7,6 +7,7 @@ use Throwable; use LogicException; use SimpleXMLElement; +use Saloon\Helpers\Helpers; use Saloon\Traits\Macroable; use InvalidArgumentException; use Saloon\Helpers\ArrayHelpers; @@ -20,6 +21,8 @@ use Psr\Http\Message\ResponseInterface; use Symfony\Component\DomCrawler\Crawler; use Saloon\Helpers\RequestExceptionHelper; +use Saloon\Contracts\DataObjects\IntoObject; +use Saloon\Contracts\DataObjects\IntoObjects; use Saloon\Contracts\DataObjects\WithResponse; use Saloon\Contracts\ArrayStore as ArrayStoreContract; @@ -301,6 +304,8 @@ public function collect(string|int|null $key = null): Collection /** * Cast the response to a DTO. + * + * @deprecated 2025-03-02 - We recommend that you use the `into` method instead which provides an improved generics experience and PHPStan/IDE support. Please see the documentation on "Data Transfer Objects" for revised instructions. */ public function dto(): mixed { @@ -318,6 +323,8 @@ public function dto(): mixed /** * Convert the response into a DTO or throw a LogicException if the response failed + * + * @deprecated 2025-03-02 - We recommend that you use the `into` method instead which provides an improved generics experience and PHPStan/IDE support. Please see the documentation on "Data Transfer Objects" for revised instructions. */ public function dtoOrFail(): mixed { @@ -345,7 +352,7 @@ public function dom(): Crawler */ public function dataUrl(): string { - return 'data:'.$this->psrResponse->getHeaderLine('Content-Type').';base64,'.base64_encode($this->body()); + return 'data:' . $this->psrResponse->getHeaderLine('Content-Type') . ';base64,' . base64_encode($this->body()); } /** @@ -660,4 +667,66 @@ public function getFakeResponse(): ?FakeResponse { return $this->fakeResponse; } + + /** + * Create an instance of a DTO from a response + * + * @template TClass of string|class-string + * + * @param TClass $class + * @return TClass + */ + public function into(string $class, bool $throw = true): mixed + { + if (! class_exists($class)) { + throw new InvalidArgumentException('The class provided does not exist.'); + } + + if (! Helpers::isSubclassOf($class, IntoObject::class)) { + throw new InvalidArgumentException(sprintf('The class provided must implement the %s interface.', IntoObject::class)); + } + + if ($throw === true) { + $this->throw(); + } + + $instance = $class::fromResponse($this); + + return $instance instanceof WithResponse + ? $instance->setResponse($this) + : $instance; + } + + /** + * Create many instances of an object from a response + * + * @template TClass of string|class-string + * + * @param TClass $class + * @return array + */ + public function intoMany(string $class, bool $throw = true): array + { + if (! class_exists($class)) { + throw new InvalidArgumentException('The class provided does not exist.'); + } + + if (! Helpers::isSubclassOf($class, IntoObjects::class)) { + throw new InvalidArgumentException(sprintf('The class provided must implement the %s interface.', IntoObjects::class)); + } + + if ($throw === true) { + $this->throw(); + } + + $instances = $class::fromResponse($this); + + if (Helpers::isSubclassOf($class, WithResponse::class)) { + foreach ($instances as $instance) { + $instance->setResponse($this); + } + } + + return $instances; + } } diff --git a/tests/Feature/ThroughToDtoTest.php b/tests/Feature/ThroughToDtoTest.php new file mode 100644 index 00000000..357e137f --- /dev/null +++ b/tests/Feature/ThroughToDtoTest.php @@ -0,0 +1,72 @@ +send($request)->into(IntoUser::class); + + expect($user)->toBeInstanceOf(IntoUser::class); + expect($user->name)->toEqual('Sammyjo20'); + }); + + test('if the class does not implement the dto interface it will throw an exception', function () { + $connector = connector(); + $request = new UserRequest; + + $connector->send($request)->into(User::class); + })->throws(InvalidArgumentException::class, 'The class provided must implement the Saloon\Contracts\DataObjects\IntoObject interface.'); + + test('if the class implements the with response interface it will populate the response', function () { + $connector = connector(); + $request = new UserRequest; + + $user = $connector->send($request)->into(IntoUserWithResponse::class); + + expect($user)->toBeInstanceOf(IntoUserWithResponse::class); + expect($user->name)->toEqual('Sammyjo20'); + expect($user->getResponse())->toBeInstanceOf(Response::class); + }); +}); + +describe('into many', function () { + test('can create many dtos using the intoMany method on a request', function () { + $connector = connector(); + $request = new PagedSuperheroRequest; + + $superheroes = $connector->send($request)->intoMany(Superhero::class); + + expect($superheroes)->toBeArray(); + expect($superheroes[0])->toBeInstanceOf(Superhero::class); + expect($superheroes[0]->name)->toEqual('Batman'); + }); + + test('if the class does not implement the dto interface it will throw an exception', function () { + $connector = connector(); + $request = new UserRequest; + + $connector->send($request)->intoMany(IntoUser::class); + })->throws(InvalidArgumentException::class, 'The class provided must implement the Saloon\Contracts\DataObjects\IntoObjects interface.'); + + test('if the class implements the with response interface it will populate the response', function () { + $connector = connector(); + $request = new PagedSuperheroRequest; + + $superheroes = $connector->send($request)->intoMany(SuperheroWithResponse::class); + + expect($superheroes)->toBeArray(); + expect($superheroes[0]->getResponse())->toBeInstanceOf(Response::class); + }); +}); diff --git a/tests/Fixtures/Data/IntoUser.php b/tests/Fixtures/Data/IntoUser.php new file mode 100644 index 00000000..391bcfcc --- /dev/null +++ b/tests/Fixtures/Data/IntoUser.php @@ -0,0 +1,26 @@ +json(); + + return new static($data['name'], $data['actual_name'], $data['twitter']); + } +} diff --git a/tests/Fixtures/Data/IntoUserWithResponse.php b/tests/Fixtures/Data/IntoUserWithResponse.php new file mode 100644 index 00000000..fd5e9838 --- /dev/null +++ b/tests/Fixtures/Data/IntoUserWithResponse.php @@ -0,0 +1,30 @@ +json(); + + return new static($data['name'], $data['actual_name'], $data['twitter']); + } +} diff --git a/tests/Fixtures/Data/Superhero.php b/tests/Fixtures/Data/Superhero.php new file mode 100644 index 00000000..ef192df0 --- /dev/null +++ b/tests/Fixtures/Data/Superhero.php @@ -0,0 +1,26 @@ +collect('data') + ->map(function (array $item): self { + return new static($item['superhero']); + }) + ->toArray(); + } +} diff --git a/tests/Fixtures/Data/SuperheroWithResponse.php b/tests/Fixtures/Data/SuperheroWithResponse.php new file mode 100644 index 00000000..f73d24a0 --- /dev/null +++ b/tests/Fixtures/Data/SuperheroWithResponse.php @@ -0,0 +1,30 @@ +collect('data') + ->map(function (array $item): self { + return new static($item['superhero']); + }) + ->toArray(); + } +}