Custom Resource GET Item
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur GET collection operation for DailyStats
is working nicely. But you know what? I'd love to also be able to fetch stats for a single day. The docs say that this operation already exists... but we know it's a lie!
If you look at the top of the DailyStats
class, we kind of added the get
item operation... but we made it return a 404 response:
// ... lines 1 - 9 | |
/** | |
* @ApiResource( | |
// ... line 12 | |
* itemOperations={ | |
* "get"={ | |
* "method"="GET", | |
* "controller"=NotFoundAction::class, | |
* "read"=false, | |
* "output"=false, | |
* }, | |
* }, | |
// ... line 21 | |
* ) | |
*/ | |
class DailyStats | |
{ | |
// ... lines 26 - 57 | |
} |
We did that as a workaround so that API Platform could generate an IRI for DailyStats
. Now I want to make this truly work.
Remove all of the custom config so that we now have a normal get item operation and a normal get collection operation:
// ... lines 1 - 9 | |
/** | |
* @ApiResource( | |
// ... line 12 | |
* itemOperations={ | |
* "get", | |
* }, | |
// ... line 16 | |
* ) | |
*/ | |
class DailyStats | |
{ | |
// ... lines 21 - 52 | |
} |
Before we do anything else, let's see what happens if we try it. Go to /api/daily-stats.jsonld
, copy the @id
for one of the daily stats, and navigate there. A 404! Oh, but let me add .jsonld
. There's the 404 in the JSON-LD format.
To get the collection operation working, we created a DailyStatsProvider
that was a collection data provider:
// ... lines 1 - 9 | |
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 12 - 18 | |
public function getCollection(string $resourceClass, string $operationName = null) | |
{ | |
// ... lines 21 - 36 | |
} | |
// ... lines 38 - 42 | |
} |
To get an item operation working, we need an item data provider. Since we don't have one yet, 404!
Adding ItemDataProviderInterface
No problem for us: we've done this before! Add another interface called ItemDataProviderInterface
:
// ... lines 1 - 5 | |
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; | |
// ... lines 7 - 11 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 14 - 49 | |
} |
Then, down here, go to "Code"->"Generate" - or Command
+N
on a Mac - select "Implement Methods" and implement the getItem()
function that we need:
// ... lines 1 - 11 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 14 - 40 | |
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []) | |
{ | |
} | |
// ... lines 45 - 49 | |
} |
Our job here is to read this $id
and return the DailyStats
object for that $id
, or null if there is none.
"Dynamic" Data from a JSON File
Before we do that, let's make all of this a bit more realistic. Our collection operation returns some hardcoded DailyStats
. Let's pretend that we have a JSON file filled with this data... or maybe in a real project, you might have an external API you could talk to in order to fetch the data.
If you downloaded the course code, then you should have a tutorial/
directory with a fake_stats.json
file inside. This will be our data source. Right next to this is a StatsHelper
class, which holds code to read from that file.
Copy both of these. Then create a new directory in src/
called Service/
and paste them both there:
{ | |
"stats": [ | |
{ | |
"date": "2020-09-03", | |
"visitors": 1500 | |
}, | |
{ | |
"date": "2020-09-02", | |
"visitors": 2435 | |
}, | |
{ | |
"date": "2020-09-01", | |
"visitors": 4853 | |
}, | |
{ | |
"date": "2020-08-31", | |
"visitors": 1942 | |
}, | |
{ | |
"date": "2020-08-30", | |
"visitors": 4323 | |
}, | |
{ | |
"date": "2020-08-29", | |
"visitors": 2969 | |
}, | |
{ | |
"date": "2020-08-28", | |
"visitors": 4929 | |
}, | |
{ | |
"date": "2020-08-27", | |
"visitors": 1949 | |
}, | |
{ | |
"date": "2020-08-26", | |
"visitors": 2834 | |
}, | |
{ | |
"date": "2020-08-25", | |
"visitors": 3949 | |
}, | |
{ | |
"date": "2020-08-24", | |
"visitors": 2632 | |
}, | |
{ | |
"date": "2020-08-23", | |
"visitors": 2213 | |
}, | |
{ | |
"date": "2020-08-22", | |
"visitors": 2250 | |
}, | |
{ | |
"date": "2020-08-21", | |
"visitors": 3567 | |
}, | |
{ | |
"date": "2020-08-20", | |
"visitors": 3710 | |
}, | |
{ | |
"date": "2020-08-19", | |
"visitors": 3310 | |
}, | |
{ | |
"date": "2020-08-18", | |
"visitors": 4034 | |
}, | |
{ | |
"date": "2020-08-17", | |
"visitors": 3453 | |
}, | |
{ | |
"date": "2020-08-16", | |
"visitors": 2346 | |
}, | |
{ | |
"date": "2020-08-15", | |
"visitors": 3567 | |
}, | |
{ | |
"date": "2020-08-14", | |
"visitors": 2020 | |
}, | |
{ | |
"date": "2020-08-13", | |
"visitors": 3923 | |
}, | |
{ | |
"date": "2020-08-12", | |
"visitors": 3944 | |
}, | |
{ | |
"date": "2020-08-11", | |
"visitors": 3244 | |
}, | |
{ | |
"date": "2020-08-10", | |
"visitors": 4566 | |
}, | |
{ | |
"date": "2020-08-09", | |
"visitors": 5321 | |
}, | |
{ | |
"date": "2020-08-08", | |
"visitors": 5499 | |
}, | |
{ | |
"date": "2020-08-07", | |
"visitors": 5422 | |
}, | |
{ | |
"date": "2020-08-06", | |
"visitors": 5683 | |
}, | |
{ | |
"date": "2020-08-05", | |
"visitors": 5662 | |
} | |
] | |
} |
Perfect. Let's take a quick look at the new StatsHelper
:
// ... lines 1 - 2 | |
namespace App\Service; | |
use App\Entity\DailyStats; | |
use App\Repository\CheeseListingRepository; | |
class StatsHelper | |
{ | |
private $cheeseListingRepository; | |
public function __construct(CheeseListingRepository $cheeseListingRepository) | |
{ | |
$this->cheeseListingRepository = $cheeseListingRepository; | |
} | |
/** | |
* @param array An array of criteria to limit the results | |
* Supported keys are: | |
* * from DateTimeInterface | |
* * to DateTimeInterface | |
* @return array|DailyStats[] | |
*/ | |
public function fetchMany(int $limit = null, int $offset = null, array $criteria = []) | |
{ | |
$fromDate = $criteria['from'] ?? null; | |
$toDate = $criteria['to'] ?? null; | |
$i = 0; | |
$stats = []; | |
foreach ($this->fetchStatsData() as $statData) { | |
$i++; | |
if ($offset >= $i) { | |
continue; | |
} | |
$dateString = $statData['date']; | |
$date = new \DateTimeImmutable($dateString); | |
if ($fromDate && $date < $fromDate) { | |
continue; | |
} | |
if ($toDate && $date > $toDate) { | |
continue; | |
} | |
$stats[$dateString] = $this->createStatsObject($statData); | |
if (count($stats) >= $limit) { | |
break; | |
} | |
} | |
return $stats; | |
} | |
public function fetchOne(string $date): ?DailyStats | |
{ | |
foreach ($this->fetchStatsData() as $statData) { | |
if ($statData['date'] === $date) { | |
return $this->createStatsObject($statData); | |
} | |
} | |
return null; | |
} | |
public function count(): int | |
{ | |
return count($this->fetchStatsData()); | |
} | |
private function fetchStatsData(): array | |
{ | |
$statsData = json_decode(file_get_contents(__DIR__.'/fake_stats.json'), true); | |
return $statsData['stats']; | |
} | |
private function getRandomItems(array $items, int $max) | |
{ | |
if ($max > count($items)) { | |
shuffle($items); | |
return $items; | |
} | |
$finalItems = []; | |
while (count($finalItems) < $max) { | |
$item = $items[array_rand($items)]; | |
if (!in_array($item, $finalItems)) { | |
$finalItems[] = $item; | |
} | |
} | |
return $finalItems; | |
} | |
private function createStatsObject(array $statData): DailyStats | |
{ | |
$listings = $this->cheeseListingRepository | |
->findBy([], [], 10); | |
return new DailyStats( | |
new \DateTimeImmutable($statData['date']), | |
$statData['visitors'], | |
$this->getRandomItems($listings, 5) | |
); | |
} | |
} |
There's nothing fancy: it has three public methods - fetchMany()
, where you can pass it a limit, offset and even some filtering criteria, which we'll talk about later - fetchOne()
, where you pass a date string and also a count()
method.
And... that's basically it. The rest of this file is boring code to read that fake_stats.json
file, parse through it and create DailyStats
objects.
Using the "Dynamic" Data
In DailyStatsProvider
let's use this! We won't need the CheeseListingRepository
anymore: StatsHelper
takes care of all of that. So autowire it instead: StatsHelper $statsHelper
, then $this->statsHelper = $statsHelper
and rename the property to $statsHelper
:
// ... lines 1 - 10 | |
use App\Service\StatsHelper; | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
private $statsHelper; | |
public function __construct(StatsHelper $statsHelper) | |
{ | |
$this->statsHelper = $statsHelper; | |
} | |
// ... lines 21 - 35 | |
} |
We can also get rid of couple of use
statements:
// ... lines 1 - 7 | |
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; | |
// ... line 9 | |
use App\Repository\CheeseListingRepository; | |
// ... lines 11 - 51 |
Down in getCollection()
, it's now as simple as return $this->statsHelper->fetchMany()
. For now, pass it no arguments:
// ... lines 1 - 12 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 15 - 21 | |
public function getCollection(string $resourceClass, string $operationName = null) | |
{ | |
return $this->statsHelper->fetchMany(); | |
} | |
// ... lines 26 - 35 | |
} |
Cool! Let's see if that works. Go back to the collection endpoint, refresh and... yes! We get a big list of DailyStats
data coming from that JSON file!
Finishing getItem()
Let's use StatsHelper
to finish getItem()
. Thanks to the supports()
method, our getItem()
method should be called every time a request is made to an "item" operation for DailyStats
:
// ... lines 1 - 12 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 15 - 31 | |
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool | |
{ | |
return $resourceClass === DailyStats::class; | |
} | |
} |
Let's make sure that's working with dd($id)
:
// ... lines 1 - 12 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 15 - 26 | |
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []) | |
{ | |
dd($id); | |
} | |
// ... lines 31 - 35 | |
} |
Back at the browser, go forward, refresh and... nice! Our date string is dumped.
Now over in getItem()
, we can return $this->statsHelper->fetchOne()
and pass it the date string, which... is the $id
variable:
// ... lines 1 - 12 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
// ... lines 15 - 26 | |
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []) | |
{ | |
return $this->statsHelper->fetchOne($id); | |
} | |
// ... lines 31 - 35 | |
} |
Testing time! Over at the browser, refresh! 404!? I mean, of course! The date in the URL is not one of the dates I have in my JSON file. Go back one page, refresh the collection endpoint and copy a different one - like 2020-09-01
.
So if we go to /api/daily-stats/2020-09-01.jsonld
, then... it works! And if we go to a date that is not in that JSON file, we get a 404.
So setting up our item operation was actually pretty easy. Next, let's talk about pagination. Because if we go back to /api/daily-stats.jsonld
and refresh... there are a lot of items here. Since we're not using Doctrine, we no longer get pagination for free. If we need it, we have to add it ourselves.
Just minor note to `StatsHelper.php` in Script under tutorial. If `fetchMany()` should get more than one item, than line 51-52 needs to be `if ($limit !== null && count($stats) >= $limit) { break;`