From 54820ca62ba928d29aa37f366a2e0e7a6a299e38 Mon Sep 17 00:00:00 2001 From: Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:26:09 +0000 Subject: [PATCH 1/3] Feature | New DTO Experience --- composer.json | 2 +- .../DataObjects/DataTransferObject.php | 13 +++++++ src/Http/Response.php | 37 ++++++++++++++++++- tests/Feature/ThroughToDtoTest.php | 35 ++++++++++++++++++ tests/Fixtures/Data/IntoUser.php | 26 +++++++++++++ tests/Fixtures/Data/IntoUserWithResponse.php | 30 +++++++++++++++ 6 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/Contracts/DataObjects/DataTransferObject.php create mode 100644 tests/Feature/ThroughToDtoTest.php create mode 100644 tests/Fixtures/Data/IntoUser.php create mode 100644 tests/Fixtures/Data/IntoUserWithResponse.php diff --git a/composer.json b/composer.json index 2b043d14..62aed6b8 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "./vendor/bin/pest -p" ], "pstan": [ - "./vendor/bin/phpstan analyse" + "./vendor/bin/phpstan analyse --memory-limit=2G" ] } } diff --git a/src/Contracts/DataObjects/DataTransferObject.php b/src/Contracts/DataObjects/DataTransferObject.php new file mode 100644 index 00000000..25ee1639 --- /dev/null +++ b/src/Contracts/DataObjects/DataTransferObject.php @@ -0,0 +1,13 @@ +psrResponse->getHeaderLine('Content-Type').';base64,'.base64_encode($this->body()); + return 'data:' . $this->psrResponse->getHeaderLine('Content-Type') . ';base64,' . base64_encode($this->body()); } /** @@ -628,4 +634,33 @@ 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, DataTransferObject::class)) { + throw new InvalidArgumentException(sprintf('The class provided must implement the %s interface.', DataTransferObject::class)); + } + + if ($throw === true) { + $this->throw(); + } + + $instance = $class::fromResponse($this); + + return $instance instanceof WithResponse + ? $instance->setResponse($this) + : $instance; + } } diff --git a/tests/Feature/ThroughToDtoTest.php b/tests/Feature/ThroughToDtoTest.php new file mode 100644 index 00000000..b9b1e18e --- /dev/null +++ b/tests/Feature/ThroughToDtoTest.php @@ -0,0 +1,35 @@ +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\DataTransferObject 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); +}); diff --git a/tests/Fixtures/Data/IntoUser.php b/tests/Fixtures/Data/IntoUser.php new file mode 100644 index 00000000..d072ca4a --- /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..3195e096 --- /dev/null +++ b/tests/Fixtures/Data/IntoUserWithResponse.php @@ -0,0 +1,30 @@ +json(); + + return new static($data['name'], $data['actual_name'], $data['twitter']); + } +} From 25712254cd456fb899bb9ab1351428a3ce9de532 Mon Sep 17 00:00:00 2001 From: Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:39:43 +0000 Subject: [PATCH 2/3] Code style --- .../DataObjects/DataTransferObject.php | 2 ++ src/Helpers/ArrayHelpers.php | 8 ++++---- src/Http/Faking/Fixture.php | 17 ++++++++++++----- src/Http/Response.php | 4 ++-- tests/Feature/ThroughToDtoTest.php | 6 ++++-- tests/Fixtures/Data/IntoUser.php | 2 +- tests/Fixtures/Data/IntoUserWithResponse.php | 4 ++-- 7 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/Contracts/DataObjects/DataTransferObject.php b/src/Contracts/DataObjects/DataTransferObject.php index 25ee1639..49779a81 100644 --- a/src/Contracts/DataObjects/DataTransferObject.php +++ b/src/Contracts/DataObjects/DataTransferObject.php @@ -1,5 +1,7 @@ $array * @param string|int|null $key - * @return array + * @return array */ - public static function set(&$array, $key, $value) + public static function set(array &$array, mixed $key, mixed $value) { if (is_null($key)) { return $array = $value; } - $keys = explode('.', $key); + $keys = explode('.', (string)$key); foreach ($keys as $i => $key) { if (count($keys) === 1) { diff --git a/src/Http/Faking/Fixture.php b/src/Http/Faking/Fixture.php index 3f20fa1b..d712220e 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; @@ -32,13 +35,15 @@ class Fixture /** * Data to merge in the mocked response. + * + * @var array|null */ protected ?array $merge = null; /** * Closure to modify the returned data with. */ - protected ?\Closure $through = null; + protected ?Closure $through = null; /** * Constructor @@ -51,6 +56,8 @@ public function __construct(string $name = '', ?Storage $storage = null) /** * Specify data to merge with the mock response data. + * + * @param array $merge */ public function merge(array $merge = []): static { @@ -62,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; @@ -87,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 @@ -97,7 +104,7 @@ public function getMockResponse(): ?MockResponse ArrayHelpers::set($body, $key, $value); } } - + // If specified, we pass the body through a function that // may modify the mock response data. if (! is_null($this->through)) { @@ -186,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 1e9a21eb..b398c5aa 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -4,11 +4,10 @@ namespace Saloon\Http; -use Saloon\Contracts\DataObjects\DataTransferObject; -use Saloon\Helpers\Helpers; use Throwable; use LogicException; use SimpleXMLElement; +use Saloon\Helpers\Helpers; use Saloon\Traits\Macroable; use InvalidArgumentException; use Saloon\Helpers\ArrayHelpers; @@ -23,6 +22,7 @@ use Symfony\Component\DomCrawler\Crawler; use Saloon\Helpers\RequestExceptionHelper; use Saloon\Contracts\DataObjects\WithResponse; +use Saloon\Contracts\DataObjects\DataTransferObject; use Saloon\Contracts\ArrayStore as ArrayStoreContract; class Response diff --git a/tests/Feature/ThroughToDtoTest.php b/tests/Feature/ThroughToDtoTest.php index b9b1e18e..115506a6 100644 --- a/tests/Feature/ThroughToDtoTest.php +++ b/tests/Feature/ThroughToDtoTest.php @@ -1,10 +1,12 @@ Date: Mon, 3 Mar 2025 19:02:22 +0000 Subject: [PATCH 3/3] Added intoMany as concept --- ...{DataTransferObject.php => IntoObject.php} | 2 +- src/Contracts/DataObjects/IntoObjects.php | 17 +++++ src/Http/Response.php | 40 ++++++++++- tests/Feature/ThroughToDtoTest.php | 71 ++++++++++++++----- tests/Fixtures/Data/IntoUser.php | 4 +- tests/Fixtures/Data/IntoUserWithResponse.php | 4 +- tests/Fixtures/Data/Superhero.php | 26 +++++++ tests/Fixtures/Data/SuperheroWithResponse.php | 30 ++++++++ 8 files changed, 168 insertions(+), 26 deletions(-) rename src/Contracts/DataObjects/{DataTransferObject.php => IntoObject.php} (89%) create mode 100644 src/Contracts/DataObjects/IntoObjects.php create mode 100644 tests/Fixtures/Data/Superhero.php create mode 100644 tests/Fixtures/Data/SuperheroWithResponse.php diff --git a/src/Contracts/DataObjects/DataTransferObject.php b/src/Contracts/DataObjects/IntoObject.php similarity index 89% rename from src/Contracts/DataObjects/DataTransferObject.php rename to src/Contracts/DataObjects/IntoObject.php index 49779a81..47b14ce6 100644 --- a/src/Contracts/DataObjects/DataTransferObject.php +++ b/src/Contracts/DataObjects/IntoObject.php @@ -6,7 +6,7 @@ use Saloon\Http\Response; -interface DataTransferObject +interface IntoObject { /** * Handle the creation of the object from Saloon diff --git a/src/Contracts/DataObjects/IntoObjects.php b/src/Contracts/DataObjects/IntoObjects.php new file mode 100644 index 00000000..e5fe21f3 --- /dev/null +++ b/src/Contracts/DataObjects/IntoObjects.php @@ -0,0 +1,17 @@ + + */ + public static function fromResponse(Response $response): array; +} diff --git a/src/Http/Response.php b/src/Http/Response.php index b398c5aa..673101b8 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -21,8 +21,9 @@ 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\DataObjects\DataTransferObject; use Saloon\Contracts\ArrayStore as ArrayStoreContract; class Response @@ -649,8 +650,8 @@ public function into(string $class, bool $throw = true): mixed throw new InvalidArgumentException('The class provided does not exist.'); } - if (! Helpers::isSubclassOf($class, DataTransferObject::class)) { - throw new InvalidArgumentException(sprintf('The class provided must implement the %s interface.', DataTransferObject::class)); + if (! Helpers::isSubclassOf($class, IntoObject::class)) { + throw new InvalidArgumentException(sprintf('The class provided must implement the %s interface.', IntoObject::class)); } if ($throw === true) { @@ -663,4 +664,37 @@ public function into(string $class, bool $throw = true): mixed ? $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 index 115506a6..357e137f 100644 --- a/tests/Feature/ThroughToDtoTest.php +++ b/tests/Feature/ThroughToDtoTest.php @@ -5,33 +5,68 @@ use Saloon\Http\Response; use Saloon\Tests\Fixtures\Data\User; use Saloon\Tests\Fixtures\Data\IntoUser; +use Saloon\Tests\Fixtures\Data\Superhero; use Saloon\Tests\Fixtures\Requests\UserRequest; use Saloon\Tests\Fixtures\Data\IntoUserWithResponse; +use Saloon\Tests\Fixtures\Data\SuperheroWithResponse; +use Saloon\Tests\Fixtures\Requests\PagedSuperheroRequest; -test('can create a dto using the into method on a request', function () { - $connector = connector(); - $request = new UserRequest; +describe('into', function () { + test('can create a dto using the into method on a request', function () { + $connector = connector(); + $request = new UserRequest; - $user = $connector->send($request)->into(IntoUser::class); + $user = $connector->send($request)->into(IntoUser::class); - expect($user)->toBeInstanceOf(IntoUser::class); - expect($user->name)->toEqual('Sammyjo20'); + 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); + }); }); -test('if the class does not implement the dto interface it will throw an exception', function () { - $connector = connector(); - $request = new UserRequest; +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)->into(User::class); -})->throws(InvalidArgumentException::class, 'The class provided must implement the Saloon\Contracts\DataObjects\DataTransferObject interface.'); + $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 UserRequest; + test('if the class implements the with response interface it will populate the response', function () { + $connector = connector(); + $request = new PagedSuperheroRequest; - $user = $connector->send($request)->into(IntoUserWithResponse::class); + $superheroes = $connector->send($request)->intoMany(SuperheroWithResponse::class); - expect($user)->toBeInstanceOf(IntoUserWithResponse::class); - expect($user->name)->toEqual('Sammyjo20'); - expect($user->getResponse())->toBeInstanceOf(Response::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 index 59df8534..391bcfcc 100644 --- a/tests/Fixtures/Data/IntoUser.php +++ b/tests/Fixtures/Data/IntoUser.php @@ -5,9 +5,9 @@ namespace Saloon\Tests\Fixtures\Data; use Saloon\Http\Response; -use Saloon\Contracts\DataObjects\DataTransferObject; +use Saloon\Contracts\DataObjects\IntoObject; -class IntoUser implements DataTransferObject +class IntoUser implements IntoObject { public function __construct( public string $name, diff --git a/tests/Fixtures/Data/IntoUserWithResponse.php b/tests/Fixtures/Data/IntoUserWithResponse.php index d803182c..fd5e9838 100644 --- a/tests/Fixtures/Data/IntoUserWithResponse.php +++ b/tests/Fixtures/Data/IntoUserWithResponse.php @@ -6,10 +6,10 @@ use Saloon\Http\Response; use Saloon\Traits\Responses\HasResponse; +use Saloon\Contracts\DataObjects\IntoObject; use Saloon\Contracts\DataObjects\WithResponse; -use Saloon\Contracts\DataObjects\DataTransferObject; -class IntoUserWithResponse implements DataTransferObject, WithResponse +class IntoUserWithResponse implements IntoObject, WithResponse { use HasResponse; 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(); + } +}