Custom Resource Data Provider

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

For the DailyStats resource, to start, I only need the get collection endpoint: we'll remove all the other operations for now.

Limiting the Operations

We can do that by saying itemOperations={} and collectionOperations={} with get inside:

... lines 1 - 6
/**
* @ApiResource(
* itemOperations={},
* collectionOperations={"get"}
* )
*/
class DailyStats
{
... lines 15 - 19
}

Try the docs now. Yep! An API Resource with only one operation. Let's try this operation to see what currently happens. I'll cheat and go directly to /api/daily-stats.jsonld. Huh, it "sort of" works... but the results are empty. This is because API Platform has no idea how to "load" "daily stats" and so... it just returns nothing. How can we teach it to load DailyStats objects? With a data provider of course!

Creating the Data Provider

Inside the DataProvider/ directory, create a new class called DailyStatsProvider:

... lines 1 - 2
namespace App\DataProvider;
... lines 4 - 7
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 10 - 16
}

Let's keep this as simple as possible: implement CollectionDataProviderInterface and RestrictedDataProviderInterface so so that we can support only the DailyStats class:

... lines 1 - 4
use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 10 - 16
}

Next, go to "Code"->"Generate" - or Command+N - on a Mac and select "Implement Methods" to generate the two methods that we need:

... lines 1 - 7
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null)
{
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
}
}

For supports, it's easy: return $resourceClass === DailyStats::class:

... lines 1 - 6
use App\Entity\DailyStats;
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 11 - 19
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === DailyStats::class;
}
}

For getCollection()... we're going to eventually load the data from a JSON file. But to start, let's create a dummy object: $stats = new DailyStats(), $stats->date = new \DateTime(), $stats->totalVisitors = 100 and we'll leave $mostPopularListings empty right now:

... lines 1 - 8
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null)
{
$stats = new DailyStats();
$stats->date = new \DateTime();
$stats->totalVisitors = 1000;
... lines 16 - 17
}
... lines 19 - 23
}

At the bottom return an array with $stats inside:

... lines 1 - 8
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null)
{
$stats = new DailyStats();
$stats->date = new \DateTime();
$stats->totalVisitors = 1000;
return [$stats];
}
... lines 19 - 23
}

You Need a "get" item Operation

Let's try it! Move over, refresh and... it works! I'm kidding. It's almost that easy... but not quite. That's a weird error though:

No item route associated with DailyStats.

Here's what's happening. Remember that JSON-LD always returns an @id property with the IRI for the resource. For an item, the IRI is the URL to the item operation: the URL you could go to, to fetch a single DailyStats resource.

The problem in this situation is that, inside DailyStats, we've specifically said that we don't want any item operations:

... lines 1 - 6
/**
* @ApiResource(
* itemOperations={},
... line 10
* )
*/
class DailyStats
{
... lines 15 - 19
}

This kind of confuses API Platform, which has a brief existential crises before saying:

How can I generate a URL to the item operation if there aren't any! Ah!

The easiest solution is to add the get itemOperation... and we are going to do that later. But let's pretend that we don't want to use that workaround because we don't want an item operation.

The solution is... well... it's still an ugly workaround. I'll paste in some config... and then I need to add a use statement for this NotFoundAction class:

... lines 1 - 5
use ApiPlatform\Core\Action\NotFoundAction;
/**
* @ApiResource(
* itemOperations={
* "get"={
* "method"="GET",
* "controller"=NotFoundAction::class,
* "read"=false,
* "output"=false,
* },
* },
... line 18
* )
*/
class DailyStats
{
... lines 23 - 27
}

So... this basically says:

I do want a get item operation... but if anybody goes to it, I want to execute the NotFoundAction which will rudely return a 404 response.

So there is an item operation... but not really.

Adding the identifier

Anyways, if we try it again... our next error!

No identifiers defined for resource DailyStats.

This is much more understandable. Every resource needs to have a unique identifier, which is used to generate the IRI. Usually this is an ID or UUID, which we'll talk about later in the tutorial.

To give our DailyStats an identifier, we could add a public $id or public $uuid... but we don't need to! Why? Because the $date is already a unique identifier: we're only going to have one DailyStats per day:

... lines 1 - 20
class DailyStats
{
public $date;
... lines 24 - 27
}

How do we tell API Platform to use this property as the identifier? In Doctrine, it happens automatically thanks to the @ORM\Id annotation:

... lines 1 - 41
class User implements UserInterface
{
/**
* @ORM\Id()
... lines 46 - 47
*/
private $id;
... lines 50 - 286
}

When you're not in an entity - or if you want to use a field other than the database id in an entity - you can add @ApiProperty() with identifier=true:

... lines 1 - 4
use ApiPlatform\Core\Annotation\ApiProperty;
... lines 6 - 21
class DailyStats
{
/**
* @ApiProperty(identifier=true)
*/
public $date;
... lines 28 - 31
}

Cool! Let's refresh and celebrate! With... another error:

DateTime could not be converted to string

Oh, duh! API Platform needs to convert our identifier into a string. Since this is a Datetime object... that doesn't work.

And... that's ok! I have a better idea. Create a new public function getDateString() that returns a string. Inside, return $this->date->format('Y-m-d'):

... lines 1 - 21
class DailyStats
{
... lines 24 - 32
public function getDateString(): string
{
return $this->date->format('Y-m-d');
}
}

Then, take the ApiProperty annotation and move it down here:

... lines 1 - 21
class DailyStats
{
public $date;
... lines 25 - 29
/**
* @ApiProperty(identifier=true)
*/
public function getDateString(): string
{
return $this->date->format('Y-m-d');
}
}

Yea! That's allowed and it's perfect: our identifier is the string date.

Try it! And... oh! Boo - that's a Ryan error. I'll fix the typo and then... victory! A beautiful JSON-LD Hydra response with one item inside. And check out the @id: /api/daily-stats/2020-09-17. That's awesome!

Oh, right... but there are no actual fields. Let's fix that next and, more importantly, look more closely at our docs. When your class is not an entity, you need to do more work so that API Platform knows what type each field is. And this is more than just for documentation: types affect how your API behaves.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "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.9.6
        "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.7.3
        "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.21.1
        "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.1.2
    }
}