67

Laravel Eloquent: API Resources

 5 years ago
source link: https://www.tuicool.com/articles/hit/QzUn2ai
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

When creating API's, we sometimes specify the data we want back in the various controller actions:

public function show(Book $book)
{
    return response()->json([
        'data' => [
            'title' => $book->title,
            'description' => $book->description,
            'author' => $book->author->name
        ]
    ]);
}

Notice we omitted the attributes created_at and updated_at when formatting the response?

Take another scenario where we want to update a book and expect a response back.

public function update(Request $request, Book $book)
{
    $book = $book->update($response->all());
    return response()->json([
        'data' => [
            'title' => $book->title,
            'description' => $book->description,
        ]
    ]);
}

We still have to format the response for the store() method and probably return the created book as part of the response.

If we want the same $book attributes returned in all responses that involve a book resource, there is a high likelihood of forgetting some attribute, especially when working with many attributes. Again having to keep track of these attributes in every controller action that involves a book resource is a hassle. Assuming we have a book component on the frontend that is reused in all the occasions that involve a book resource. We are going to run into a problem when one of the attributes is missing.

To handle inconsistencies in API resource responses, we may update the model with what should be returned when we call a model instance.

Still working with the book example:

// Book Model
protected $hidden = ['created_at', 'updated_at'];

This means the created_at and updated_at attributes won't be part of the response everytime we call on book resource.

But then again, how do we go about adding custom attributes that are not part of the original model:

protected $appends = ['date_stored'];

public function getDateStored()
{
    return (string) $this->created_at->diffForHumans();
}

And that's just one custom attribute. Had we wanted to include many attributes to be part of the model's response, trust me we are going to end up with one bloated model. It's a lot easier to create a dedicated resource to respond with exactly the data one needs.

Before Laravel 5.5, Fractal, a third party package was the tool most developers used to format API responses. Fractal provides a presentation and transformation layer for complex data output, the likes found in RESTful APIs, and works really well with JSON. Think of this as a view layer for your JSON/YAML/etc. Fractal encourages good API design, and responses will be consistent across the API.

Introducing Laravel API resources

As of Laravel 5.5, Laravel has the capabilities Fractal offered with very little configuration. Setting up Fractal was a bit of a process - require the package, register service providers, create transform classes and so forth.

With API resources, developers can easily specify the data they want to be returned per model basis without having to update models or even specifying the attributes they want to be part of the response in the various controller methods.

API resources provide a uniform interface that can be used anywhere in the app. Eloquent relationships are also taken care of.

Laravel provides two artisan commands for generating resources and collections - don't worry about the difference between the two yet, we'll get there in a bit. But for both resources and collections, we have our response wrapped in a data attribute; a JSON response standard.

We'll look at how to work with API resources in the next section by playing around with a small project.

To follow along in this artilce, you need to have the following prerequisites

  • Basic Laravel knowledge

  • A working Laravel development environment. Note, the project is built on Laravel 5.6 which requires PHP >= 7.1.3 .

Clone this repo and follow the instructions in the README.md to get things up and running.

With the project setup, we can now start getting our hands dirty. Also, since this is a very small project, we won't be creating any controllers and will instead test out responses inside route closures.

Let's start by generating a SongResource class:

php artisan make:resource SongResource

If we peek inside the newly created resource file i.e. SongResource ( Resouce files usually go inside the App \ Http \ Resources folder ), the contents look like this:

[...]
class SongResource extends JsonResource
{
    /_*
     _ Transform the resource into an array.
     _
     _ @param  \Illuminate\Http\Request  $request
     _ @return array
     _/
    public function toArray($request)
    {
        return parent::toArray($request);
    }
}

By default, we have parent::toArray($request) inside the toArray() method. If we leave things at this, all visible model attributes will be part of our response. To tailor the response, we specify the attributes we want to be converted to JSON inside this toArray() method.

Let's update the toArray() method to match the snippet below:

public function toArray($request)
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'rating' => $this->rating,
    ];
}

As you can see, we can access the model properties directly from the $this variable because a resource class automatically allows method access down to the underlying model.

Let's now update the routes/api.php with the snippet below:

# routes/api.php

[...]
use App\Http\Resources\SongResource;
use App\Song;
[...]

Route::get('/songs/{song}', function(Song $song) {
    return new SongResource($song);
});

Route::get('/songs', function() {
    return new SongResource(Song::all());
});

If we visit the URL /api/songs/1 , we'll see a JSON response containing the key-value pairs we specified in the SongResource class for the song with an id of 1 :

{
  data: {
    id: 1,
    title: "Mouse.",
    rating: 3
  }
}

However, if we try visiting the URL /api/songs , an Exception is thrown Property [id] does not exist on this collection instance.

This is because instantiating the SongResource class requires a resource instance be passed to the constructor and not a collection. That's why the exception is thrown.

If we wanted a collection returned instead of a single resource, there is a static collection() method that can be called on a Resource class passing in a collection as the argument. Let's update our songs route closure to this:

Route::get('/songs', function() {
    return SongResource::collection(Song::all());
});

Visiting the /api/songs URL again will give us a JSON response containing all the songs.

{
  data: [{
      id: 1,
      title: "Mouse.",
      rating: 3
    },
    {
      id: 2,
      title: "I'll.",
      rating: 0
    }
  ]
}

Resources work just fine when returning a single resource or even a collection but have limitations if we want to include metadata in the response. That's where Collections come to our rescue.

To generate a collection class, we run:

php artisan make:resource SongsCollection

The main difference between a JSON resource and a JSON collection is that a resource extends the JsonResource class and expects a single resource to be passed when being instantiated while a collection extends the ResourceCollection class and expects a collection as the argument when being instantiated.

Back to the metadata bit. Assuming we wanted some metadata such as the total song count to be part of the response, here's how to go about it when working with the ResourceCollection class:

class SongsCollection extends ResourceCollection
{
    public function toArray($request)
    {
        return [
            'data' => $this->collection,
            'meta' => ['song_count' => $this->collection->count()],
        ];
    }
}

If we update our /api/songs route closure to this:

[...]
use App\Http\Resources\SongsCollection;
[...]
Route::get('/songs', function() {
    return new \SongsCollection(Song::all());
});

And visit the URL /api/songs , we now see all the songs inside the data attribute as well as the total count inside the meta bit:

{
  data: [{
      id: 1,
      title: "Mouse.",
      artist: "Carlos Streich",
      rating: 3,
      created_at: "2018-09-13 15:43:42",
      updated_at: "2018-09-13 15:43:42"
    },
    {
      id: 2,
      title: "I'll.",
      artist: "Kelton Nikolaus",
      rating: 0,
      created_at: "2018-09-13 15:43:42",
      updated_at: "2018-09-13 15:43:42"
    },
    {
      id: 3,
      title: "Gryphon.",
      artist: "Tristin Veum",
      rating: 3,
      created_at: "2018-09-13 15:43:42",
      updated_at: "2018-09-13 15:43:42"
    }
  ],
  meta: {
    song_count: 3
  }
}

But we have a problem, each song inside the data attribute is not formatted to the specification we defined earlier inside the SongResource and instead has all attributes.

To fix this, inside the toArray() method, set the value of data to SongResource::collection($this->collection) instead of having $this->collection .

Our toArray() method should now look like this:

public function toArray($request)
{
    return [
        'data' => SongResource::collection($this->collection),
       'meta' => ['song_count' => $this->collection->count()]
    ];
}

You can verify we get the correct data in the response by visiting the /api/songs URL again.

What if one wants to add metadata to a single resource and not a collection? Luckily, the JsonResource class comes with an additional() method which lets you specify any additional data you'd like to be part of the response when working with a resource:

Route::get('/songs/{song}', function(Song $song) {
    return (new SongResource(Song::find(1)))->additional([
        'meta' => [
            'anything' => 'Some Value'
        ]
    ]);
})

In this case, the response would look somewhat like this:

{
  data: {
    id: 1,
    title: "Mouse.",
    rating: 3
  },
  meta: {
    anything: "Some Value"
  }
}

###

Including Relationships

In this project, we only have two models, Album and Song . The current relationship is a one-to-many relationship, meaning an album has many songs and a song belongs to an album.

Making an album be part of a song's response is pretty straightforward. Let's update the toArray() method inside the SongResource to take note of the album:

class SongResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            // other attributes
            'album' => $this->album
        ];
    }
}

If we want to be more specific in terms of what album attributes should be present in the response, we can create an AlbumResource similar to what we did with songs.

To create the AlbumResource we run:

php artisan make:resource AlbumResource

Once the resource class has been created, we then specify the attributes we want to be included the response.

class AlbumResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'title' => $this->title
        ];
    }
}

And now inside the SongResource class, instead of doing 'album' => $this->album , we can make use of the AlbumResource class we just created.

class SongResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            // other attributes
            'album' => new AlbumResource($this->album)
        ];
    }
}

If we visit the /api/songs URL again, you’ll notice an album will be part of the response. The only problem with this approach is that it brings up the N + 1 query problem.

For demonstration purposes, add the snippet below inside the api/routes file:

# routes/api.php

[...]
\DB::listen(function($query) {
    var_dump($query->sql);
});

Visit the /api/songs URL again. Notice that for each song, we make an extra query to retrieve the album's details? This can be avoided by eager loading relationships. In our case, update the code inside the /api/songs route closure to:

return new SongsCollection(Song::with('album')->get());

Reload the page again and you'll notice the number of queries has reduced. Comment out the \DB::listen snippet since we don't need that anymore.

Conditionals When Working With Resources

Every now and then, we might have a conditional determining the type of response that should be returned.

One approach we could take is introducing if statements inside our toArray() method. The good news is we don't have to do that as there is a ConditionallyLoadsAttributes trait required inside the JsonResource class that has a handful of methods for handling conditionals. Just to mention a few, we have the when() , whenLoaded() and mergeWhen() methods.

We'll only brush through a few of these methods, but the documentation is quite comprehensive.

####

The whenLoaded() method

This method prevents data that has not been eager loaded from being loaded when retrieving related models thereby preventing the (N+1) query problem.

Still working with the Album resource as a point of reference ( an album has many songs ):

public function toArray($request)
{
    return [
        // other attributes
        'songs' => SongResource::collection($this->whenLoaded($this->songs))
    ];
}

In the case where we are not eager loading songs when retrieving an album, we'll end up with an empty songs collection.

The mergeWhen() Method

Instead of having an if statement that dictates whether some attribute and its value should be part of the response, we can use the mergeWhen() method which takes in the condition to evaluate as the first argument and an array containing key-value pair that is meant to be part of the response if the condition evaluates to true:

public function toArray($request)
{
    return [
        // other attributes
        'songs' => SongResource::collection($this->whenLoaded($this->songs)),
        this->mergeWhen($this->songs->count > 10, ['new_attribute' => 'attribute value'])
    ];
}

This looks cleaner and more elegant instead of having if statements wrapping the entire return block.

Unit Testing API Resources

Now that we've learnt how to transform our responses, how do we actually verify that the response we get back is what we specified in our resource classes?

Here, we'll write tests verifying the response contains the correct data as well making sure eloquent relationships are still maintained.

Let's create the test:

php artisan make:test SongResourceTest --unit

Notice I passed the --unit flag when generating the test to tell Laravel this should be a unit test.

Let's start by writing the test to make sure our response from the SongResource class contains the correct data:

[...]
use App\Http\Resources\SongResource;
use App\Http\Resources\AlbumResource;
[...]
class SongResourceTest extends TestCase
{
    use RefreshDatabase;
    public function testCorrectDataIsReturnedInResponse()
    {
        $resource = (new SongResource($song = factory('App\Song')->create()))->jsonSerialize();
    }
}

Here, we first create a song resource then call jsonSerialize() on the SongResource to transform the resource into JSON format, as that's what should be sent to our front-end ideally.

And since we already know the song attributes that should be part of the response, we can now make our assertion:

$this->assertArraySubset([
    'title' => $song->title,
    'rating' => $song->rating
], $resource);

I only matched against two attributes and their corresponding values to keep things simple but you can list as many attributes as you would like.

What about making sure our model relationships are preserved even after converting our models to resources?

public function testSongHasAlbumRelationship()
{
    $resource = (new SongResource($song = factory('App\Song')->create(["album_id" => factory('App\Album')->create(['id' => 1])])))->jsonSerialize();
}

Here, we create a song with an album_id of 1 then pass the song on to the SongResource class before finally transforming the resource into JSON format.

To verify that the song-album relationship is still maintained, we make an assertion on the album attribute of the $resource we just created. Like so:

$this->assertInstanceOf(AlbumResource::class, $resource["album"]);

Note, however, if we did $this->assertInstanceOf(Album::class, $resource["album"]) our test would fail since we are transforming the album instance into a resource inside the SongResource class.

As a recap, we first create a model instance, pass the instance to the resource class, convert the resource into JSON format before finally making the assertions. I hope this helps.

Congratulations if you have managed to get to this point. We've looked at what Laravel API resources are, how to create them as well as how to test out various JSON responses. If you are the curious type, you can peep inside the JsonResource class and see all the methods that are available to us.

Do check the official docs to learn more about API resources. The complete code for this tutorial is available on GitHub .


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK