Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Contracts/DataObjects/IntoObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Saloon\Contracts\DataObjects;

use Saloon\Http\Response;

interface IntoObject
{
/**
* Handle the creation of the object from Saloon
*/
public static function fromResponse(Response $response): static;
}
17 changes: 17 additions & 0 deletions src/Contracts/DataObjects/IntoObjects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Saloon\Contracts\DataObjects;

use Saloon\Http\Response;

interface IntoObjects
{
/**
* Handle the creation of the object from Saloon
*
* @return array<self>
*/
public static function fromResponse(Response $response): array;
}
11 changes: 7 additions & 4 deletions src/Http/Faking/Fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,7 +43,7 @@ class Fixture
/**
* Closure to modify the returned data with.
*/
protected ?\Closure $through = null;
protected ?Closure $through = null;

/**
* Constructor
Expand All @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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
{
Expand Down
71 changes: 70 additions & 1 deletion src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Throwable;
use LogicException;
use SimpleXMLElement;
use Saloon\Helpers\Helpers;
use Saloon\Traits\Macroable;
use InvalidArgumentException;
use Saloon\Helpers\ArrayHelpers;
Expand All @@ -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;

Expand Down Expand Up @@ -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
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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());
}

/**
Expand Down Expand Up @@ -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<TClass>
*/
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;
}
}
72 changes: 72 additions & 0 deletions tests/Feature/ThroughToDtoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

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;

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);

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);
});
});
26 changes: 26 additions & 0 deletions tests/Fixtures/Data/IntoUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Data;

use Saloon\Http\Response;
use Saloon\Contracts\DataObjects\IntoObject;

class IntoUser implements IntoObject
{
public function __construct(
public string $name,
public string $actualName,
public string $twitter,
) {
//
}

public static function fromResponse(Response $response): static
{
$data = $response->json();

return new static($data['name'], $data['actual_name'], $data['twitter']);
}
}
30 changes: 30 additions & 0 deletions tests/Fixtures/Data/IntoUserWithResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Data;

use Saloon\Http\Response;
use Saloon\Traits\Responses\HasResponse;
use Saloon\Contracts\DataObjects\IntoObject;
use Saloon\Contracts\DataObjects\WithResponse;

class IntoUserWithResponse implements IntoObject, WithResponse
{
use HasResponse;

public function __construct(
public string $name,
public string $actualName,
public string $twitter,
) {
//
}

public static function fromResponse(Response $response): static
{
$data = $response->json();

return new static($data['name'], $data['actual_name'], $data['twitter']);
}
}
26 changes: 26 additions & 0 deletions tests/Fixtures/Data/Superhero.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Data;

use Saloon\Http\Response;
use Saloon\Contracts\DataObjects\IntoObjects;

class Superhero implements IntoObjects
{
public function __construct(
public string $name,
) {
//
}

public static function fromResponse(Response $response): array
{
return $response->collect('data')
->map(function (array $item): self {
return new static($item['superhero']);
})
->toArray();
}
}
30 changes: 30 additions & 0 deletions tests/Fixtures/Data/SuperheroWithResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Saloon\Tests\Fixtures\Data;

use Saloon\Http\Response;
use Saloon\Traits\Responses\HasResponse;
use Saloon\Contracts\DataObjects\IntoObjects;
use Saloon\Contracts\DataObjects\WithResponse;

class SuperheroWithResponse implements IntoObjects, WithResponse
{
use HasResponse;

public function __construct(
public string $name,
) {
//
}

public static function fromResponse(Response $response): array
{
return $response->collect('data')
->map(function (array $item): self {
return new static($item['superhero']);
})
->toArray();
}
}