Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Our 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.

Leave a comment!

7
Login or Register to join the conversation
Peter L. Avatar
Peter L. Avatar Peter L. | posted 2 years ago

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

1 Reply
Fredbadlieutenant Avatar
Fredbadlieutenant Avatar Fredbadlieutenant | Peter L. | posted 2 years ago

+1 Thanks !!! if($limit && count($stats) >= $limit) to get the list of DailyStats (when calling fetchMany() without args)

Reply

Hey Peter,

Nice catch! Actually, both are valid. In the current "if" statement if the $limit variable is null - it will be converted to integer anyway, i.e. it will be converted to 0 and it so it should still work. Because it's already on the video, and we do want our code matches the video, I'd prefer to keep it unchanged. But feel free to be more explicit in your code!

Cheers!

Reply
m3tal Avatar

When GraphQL and Symfony will be released???
Thank you.

1 Reply

Hey m3tal!

We don't currently have any plans to release a tutorial specifically about API Platform's GraphQL integration, but I'll add your vote to a tracker we keep internally.

Sorry I can't give you a better answer!

Cheers!

Reply
Sebastian H. Avatar
Sebastian H. Avatar Sebastian H. | posted 2 years ago

You talk about a further part of this series? When this will release?

Reply

Hi Sebastian H.!

I don't have a date right now, and unfortunately, it's behind several other tutorials at the moment! I know that's frustrating - even though this is behind a few, I consider it a high priority tutorial. If you have any specific questions about that topic, we'd be happy to do our best to answer them.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0
    }
}
userVoice