Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.5] [WIP] Transformable Responses #18502

Closed
wants to merge 8 commits into from
Closed

[5.5] [WIP] Transformable Responses #18502

wants to merge 8 commits into from

Conversation

Aferz
Copy link
Contributor

@Aferz Aferz commented Mar 26, 2017

Why ?

The purpose of this pull request is to give Laravel better flexibility when responding to API requests.

I have created a new TransformableResponse class that allows to define a few rulesets for transform the response in multiple forms that will be passed to the client.

Its design is highly inspired in FormRequests classes. If we can define Requests individually, why don't extend this behaviour to responses?

What is included in this PR ?

First of all, I suggest to store our Response classes inside app/Http/Responses folder(A nice artisan command will be included out of the box). Once said this ...

Class structure

class GetTestsResponse extends TransformableResponse
{
    /**
     * Set visibility rules to apply to current response.
     *
     * @return array
     */
    public function visibilityRules()
    {
        return [];
    }
    
   /**
     * Set casting rules to apply to current response.
     *
     * @return array
     */
    public function castingRules()
    {
        return [];
    }

    /**
     * Set mutation rules to apply to current response.
     *
     * @return array
     */
    public function mutationRules()
    {
        return [];
    }
    
    /**
     * Set renaming rules to apply to current response.
     *
     * @return array
     */
    public function renamingRules()
    {
        return [];
    }
}

Dummy Data

We will use this dummy data for demo purposes:

[
  'id' => '1',
  'name' => 'Name #1',
  'surname' => 'Surname #1',
  'posts' => [
      [
          'id' => '1',
          'title' => 'Post #1',
          'comments' => [
              [
                  'id' => '1',
                  'title' => 'Comment #1-1'
              ]
          ]
      ],
      [
          'id' => '2',
          'title' => 'Post #2',
          'comments' => [
              [
                  'id' => '1',
                  'title' => 'Comment #2-1'
              ]
          ]
      ]
  ]
]

Defining visibility rules

This ruleset define what fields will be present once the response is emitted. It's the first ruleset to be executed.

   /**
     * Set visibility rules to apply to current response.
     *
     * @return array
     */
    public function visibilityRules()
    {
        return [
            'id' => true,
            'name' => true,
            'posts.*.id' => true,
            'posts.*.comments.*.id' => true
        ];
    }

This will be the resultant response:

{
   "id":"1",
   "name":"Name #1",
   "posts":[
      {
         "id":"1",
         "comments":[
            {
               "id":"1"
            }
         ]
      },
      {
         "id":"2",
         "comments":[
            {
               "id":"1"
            }
         ]
      }
   ]
}
How does this work ?

As you can see in the rules section you define which attributes should be visible and which attributes should be hidden. So:

  1. Fields checked as true will be evaluated first. If none, all will be visible by default.
  2. Fields checked as false will be hidden using the data passed from the previous process.

Easy.

Defining casting rules

This ruleset define which fields should be casted. Valid casting values are: int, integer, real, float, double, string, bool, boolean. It's the second ruleset to be executed.

    /**
     * Set casting rules to apply to current response.
     *
     * @return array
     */
    public function castingRules()
    {
        return [
            'id' => 'int',
            'posts.*.id' => 'int',
            'posts.*.comments.*.id' => 'int'
        ];
    }

This will be the resultant response (chained from first ruleset):

{
   "id":1,
   "name":"Name #1",
   "posts":[
      {
         "id":1,
         "comments":[
            {
               "id":1
            }
         ]
      },
      {
         "id":2,
         "comments":[
            {
               "id":1
            }
         ]
      }
   ]
}

Defining mutation rules

This ruleset define how the data should be mutated. You can define your own mutators in the own class, just prefix the name of your method with the word mutator. If you want to reuse mutators you always can inherit from a parent class 👍 . It's the third ruleset to be executed.

    /**
     * Set mutation rules to apply to current response.
     *
     * @return array
     */
    public function mutationRules()
    {
        return [
            'id' => 'double|uppercase',
            'name' => 'uppercase',
            'posts.*.id' => 'double',
            'posts.*.comments.*.id' => 'double'
        ];
    }

    public function mutatorDouble($value)
    {
        return $value * 2;
    }
    
    public function mutatorUppercase($value)
    {
        return strtoupper($value);
    }

This will be the resultant response (chained from first and second ruleset):

{
   "id":"2",
   "name":"NAME #1",
   "posts":[
      {
         "id":2,
         "comments":[
            {
               "id":2
            }
         ]
      },
      {
         "id":4,
         "comments":[
            {
               "id":2
            }
         ]
      }
   ]
}

Defining renaming rules

This ruleset define how the keys of your data should be renamed. It's the forth ruleset to be executed.

    /**
     * Set renaming rules to apply to current response.
     *
     * @return array
     */
    public function renamingRules()
    {
        return [
            'id' => 'user_id',
            'posts.*.id' => 'post_id',
            'posts.*.comments.*.id' => 'comment_id'
        ];
    }

This will be the resultant response (chained from first, second and third ruleset):

{
   "user_id":"2",
   "name":"NAME #1",
   "posts":[
      {
         "post_id":2,
         "comments":[
            {
               "comment_id":2
            }
         ]
      },
      {
         "post_id":4,
         "comments":[
            {
               "comment_id":2
            }
         ]
      }
   ]
}

How this feature will be used ?

Simple. Look at this controller method:

    public function index()
    {
        $users = User::all(); // Our dummy data
        
        return new GetTestsResponse($users, 200, []); // status and headers are optional
    }

Why is a WIP ?

There are a few things to be covered before merge this PR, but first I want to check if this is useful for the Laravel community.

TODO:

  • php artisan make:response MyCustomResponse command.
  • Tests to cover this PR.

Any idea ? Any feedback ? Let me know what you all think!

Thanks !

@feryardiant
Copy link

Woa, really nice to see someone come to this idea.

I've done something similar before, using @thephpleague/fractal on Laravel 5.4, the implementation was pretty strike forward, nothing's comlicated & it support Paginators well.

IMO I'm go with fractal.

@Aferz
Copy link
Contributor Author

Aferz commented Mar 26, 2017

Yeah, before make this PR I've reviewed Fractal, but IMO I think that It doesn't really fit with a nice Laravel approach. For me It was too verbose.

I thought that filter through data needed to be easier, that's why I used validation rules style w\ custom classes to represent a specific response for a specific request instead of injecting multiple classes.

Implement pagination is easy, I'm just waiting to an oficial response to see if this is useful.

Thanks for your feedback @feryardiant !!

@konigbach
Copy link

Nice and elegant approach! Fractal is more confuse and verbose and this gives us the simplicity to transform the data the way it should be. Great job!

@ggcasuso
Copy link
Contributor

Definitely more useful than Fractal! Good work!

@devcircus
Copy link
Contributor

devcircus commented Mar 27, 2017

It may be overkill, but did you consider adding the ability to mutate the data? We can already define mutators and casts, but it would be nice to be able to define all of this in one custom response class. With this, I would drop Fractal completely. However, you probably don't want to add a bunch of unneeded complexity.

Either way, nice work! If not accepted, I'll probably use in some of my api projects anyway.

@Aferz
Copy link
Contributor Author

Aferz commented Mar 27, 2017

@devcircus What do you mean with mutate the data ?

Do you mean mutate with something like posts.*.commentsCount + 1 ? If yes, I won't because in my opinion, response object is not responsable for mutate the data this way.

If you mean mutate the schema (replace A key for B key when responding), yes, that's something that I have in mind if this PR goes forward.

I'm glad you like it. Thanks!

@JairoRC
Copy link

JairoRC commented Mar 27, 2017

+1! very useful

@devcircus
Copy link
Contributor

By mutate, I mean simply altering the presentation, but yes, (A) changing the reference(key) and/or (B) changing how the data is presented, similar to how Fractal does it.

This is something that can already be handled by presenters, accessors, etc. I just thought, notwithstanding SRP, that it would be nice to be able to fully define the response inside a custom response class.

@deleugpn
Copy link
Contributor

Maybe a decorator outside of core would be better for that?

@Aferz
Copy link
Contributor Author

Aferz commented Mar 27, 2017

Ok, now I see what you mean.

I don't really mind to try to figure out how we could encapsulate that presentation logic if that does not reject this PR (because that's not the main purpose of this feature).

Let's see what @taylorotwell or @GrahamCampbell think about this.

/**
* Transforms the response data.
*
* @param array $data
* @param array $data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please revert

@Aferz
Copy link
Contributor Author

Aferz commented Mar 27, 2017

Hi guys,

I've updated the main message to reflect some updates I made in this package:

  • Casting Rules
  • Mutation Rules
  • Renaming Rules

Thanks

* @param int $status
* @param array $headers
* @param int $options
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing return void annotation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied the constructor from JsonResponse, should I apply equally?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a mistake there then. Laravel always includes return annotations, on all methods, without exception.

/**
* Transforms the response data.
*
* @param array $data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cs

*
* Example: posts.*.comments.*.title => posts.0.comments.0.title
*
* @param array $data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cs

* Sanitize gathered array rules removing those that don't appear into
* orginal rules.
*
* @param string $rule
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cs, etc


/**
* Sanitize gathered array rules removing those that don't appear into
* orginal rules.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please shorten to 80 chars including 5 spaces, star, and space

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

80 chars length only applies to DocBlocks ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's something we apply (ish) to the short descriptions to stop them getting too long. One rule that is strict is that they cannot overflow onto the next line.

{
switch ($this->getCastType($attribute)) {
case 'int':
case 'integer':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lots of code duplication here with eloquent

Copy link
Contributor Author

@Aferz Aferz Mar 27, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, thats something I wanted to review here.

I didn't want to refactor Eloquent to use the switch that it uses to define the casting. What should I do here ?

@kavinsky
Copy link

👍 I like the second approach, but casting and mutating should be unified.

* Constructor.
*
* @param mixed $data
* @param int $status
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use exactly 2 spaces before the dollar for param annotations on new code, rather than aligning.

protected function getCastType($attribute)
{
if (! $castings = $this->castingRules()) {
return null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just return; gives the same semantics

* Get casting rule for given attribute.
*
* @param string $attribute
* @return bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bool|null


/**
* Sanitize gathered array rules removing those that
* don't appear into orginal rules.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these descriptions cannot span more than one line.


/**
* Resolve array rules generating a new rule for every
* item in the array specified with the '*' symbol.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

@fernandobandeira
Copy link
Contributor

I think you should remove abstract from those methods since most of the times we won't use the 4 methods on our child class... Making them optional would be better IMO.

Also this seems pretty good so far... 👍

@Aferz
Copy link
Contributor Author

Aferz commented Mar 28, 2017

It seems reasonable. I will modify It.

Thanks

*/
public function __construct($data = null, $status = 200, $headers = [], $options = 0)
{
if (is_array($data)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if data implements \Illuminate\Contracts\Support\Jsonable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also allow any object to implement a \Illuminate\Contracts\Support\Transformable interface, so that the object itself can always control the way it's transformed.

Copy link
Contributor Author

@Aferz Aferz Mar 28, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How we should treat \Illuminate\Contracts\Support\Jsonable ?? I mean, I have no way to determine how to transform the data if I can't get it. Any suggestion ?

I like the idea of \Illuminate\Contracts\Support\Transformable, but how do we should treat a Collection of Transformables ? Should Transformable prevail against Arrayable ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How we should treat \Illuminate\Contracts\Support\Jsonable ??

Have a look at \Illuminate\Routing\Router::prepareResponse

How do we should treat a Collection of Transformables

If data is a Collection, map all its items

Should Transformable prevail against Arrayable

I believe so


return array_reduce(array_keys($applicableRules),
function ($transformedData, $rule) {
if (Arr::has($transformedData, $rule)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\Illuminate\Support\Arr::forget always make sure that the array key exist, so using \Illuminate\Support\Arr::has is useless.

* @param mixed $value
* @param string $mutators
* @return mixed
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @throws \Exception

if (! method_exists($this, $method)) {
$classname = static::class;

throw new \Exception("There is no mutator [$method] declared in [$classname].");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please import \Exception


return array_reduce($gatheredRules,
function ($validRules, $rule) use ($pattern, $valueForValidOnes) {
preg_match($pattern, $rule, $matches);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is enough as we only want to re-use the $subject argument.

if (preg_match($pattern, $rule)) {
    $validRules[$rule] = $valueForValidOnes;
}

}

/**
* Sanitize rules removing those that don't appear into orginal rules.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: original

}

/**
* Apply visbility rules to given data.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: visibility

@lucasmichot
Copy link
Contributor

@Aferz - how do you handle $original variable ?
If I pass a a Collection as data in my custom Response, I want to be sure to actually retrieve it

So $this->getOriginal() should return my Collection
And $this->getData() should return my transformed content...

@Aferz
Copy link
Contributor Author

Aferz commented Mar 28, 2017

@lucasmichot

In constructor:

if (is_array($data)) {
    $transformedData = $this->transform($data);
} elseif ($data instanceof Arrayable) {
    $transformedData = $this->transform($data->toArray());
} else {
   $transformedData = $data;
}

parent::__construct($transformedData, $status, $headers, $options);

$this->original = $data;

should do the trick.

  • $this->getOriginalContent() will return given Collection or data.
  • $this->getData() will return transformed content.

I would like to think it a bit more because $this->original = $data will be set in parent setData method and it will be reasigned again once the JsonResponse constructor ends. Anyway, is not a big problem...

I will update this PR with your suggestions tomorrow.

Thanks for the feedback!

@jaketoolson
Copy link

If this gets approved yet #14357 (simple Transformer) PR was not... that would be a shame.

@Aferz
Copy link
Contributor Author

Aferz commented Mar 30, 2017

I've updated this PR with following changes:

  • Now you can access original data passed to custom response with getOriginalData() parent method.
  • Removed abstract methods to cherry pick what ruleset should be applied to custom response.
  • Added Illuminate\Support\Contracts\Transformable to allow object to define how it should be mapped.
  • If data is a Illuminate\Support\Collection class, its items are mapped checking (In order):
    • $item instanceof Transformable
    • or $item instanceof Arrayable
    • or returning $item itself.
  • Improved renaming feature.
  • Paginator/LengthAwarePaginator works right out the box

TODO:

  • Command for generate custom responses.
  • Tests.

/**
* Handle how a collection will be transformed.
*
* @param Collection $data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fqnc required please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops! Fixed.

*
* @param string $mutators
* @param mixed $value
* @throws Exception
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please follow our cs regarding throws docs. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry but what do you eaxctly mean ??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* @throws \Illuminate\Auth\Access\AuthorizationException

@GrahamCampbell
Copy link
Member

I still wonder if this whole thing would be best suited as a laravel package, rather than shipping in the core.

@Aferz
Copy link
Contributor Author

Aferz commented Mar 30, 2017

@GrahamCampbell - That something that we all should talk about, but I'm gonna give my 2 cents.

So far, Laravel offers support/utilities for build an API (Auth & JWT, separated API routes file, Throttling, etc...) but one of the most difficult things is how you display your data and what you show and what you don't for your multiple requests. Yes, you have $hidden attribute it your models, but when you try to display, for example, different model data depending on the Request, you don't have enough with a single attribute. That could be the main reason why there are several pull requests about transforming data.

This could be a package? Of course. But IMO I think Laravel should provide 'something' to filter your response before sending to a client (There is no a easy/clean way so far to make this). This way you gain full control from the way your request is validated to how your response is displayed.

So far, Laravel treats every Request as unique (If you want, of course), but not the Response. Conceptually, that's something that could be improved to gain granular control about your code. And that's what this PR wants to achieve.

Anyways, this is my opinion. I would like to know what you all think, if you share my concept about 'unique Response' and if this PR should be accepted or rejected.

I would like to know what @taylorotwell thinks about this to know if this worth it.

@taylorotwell
Copy link
Member

I'm not ready to commit to merging this into the core at this time (or any other fractal API type package). However, I will definitely be revisiting all of this before the 5.5 release and will add this PR to my list of PRs to look over again when I get to that point. Thanks.

@Aferz
Copy link
Contributor Author

Aferz commented Mar 30, 2017

Mmmh ok then.

@joshmanders
Copy link
Contributor

@Aferz

There is no a easy/clean way so far to make this

That's not true at all.. Whatever you return from your controller is formatted as a response. You can return anything, and easily intercept and format it however you like. I'm doing it right now. I return a class that extends a certain class, and I typecheck and format. It's pretty easy AND straight forward.

@Aferz
Copy link
Contributor Author

Aferz commented Jun 5, 2017

Hi @taylorotwell , sorry for bothering you again.

5.5 release is near so... Will this PR take in consideration for 5.5 or higher versions??.

Just for keep updating this PR with useful stuff and improvements or think of it as a possible package.

Thanks

@fernandobandeira
Copy link
Contributor

@Aferz take a look on #19449

@Aferz Aferz mentioned this pull request Jun 5, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet