Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

10
Login or Register to join the conversation
Alex H. Avatar
Alex H. Avatar Alex H. | posted 2 years ago

Hey guys, I have one problem related to custom data provider.

What I need:
Need to group data from specific table. So the data collection not will return array of entities but just array of arrays (because doctrine not will be able to transform these data into entity).

I need to transform these arrays into specific dto (Output DTO that I use).

I thought that DataTransformer will help me but it's not. It just not triggered if data provider not return array of entities.

What is the best solution for this ?

PS. I'm not using custom controller with invoke method because I will lose some features of api platform. In my data provider I support filters and so on.

Reply

Hey Alex H.!

Hmm. This sounds a bit like a custom API resource entirely to me. Normally, I would also think to use an output DTO. But that assumes that your data provider would still return an array of entities... and then you would simply transform those in some way. This sounds more extreme (not in a bad way): you want to return a completely different set of data.

So, that's my first thought: a custom API resource class entirely. But I know that those can be annoying, as you need to re-add filtering, etc manually :/. Unfortunately, at the moment, I can't think of another decent way to do this.

Sorry I can't help more! Let me know if I've misunderstood any part of your question. When you said "need to group data", I assume that you are actually doing some sort of "GROUP BY" in your query, and so the results are very different than normal "rows in the table".

Cheers!

Reply
Alex H. Avatar
Alex H. Avatar Alex H. | weaverryan | posted 1 year ago | edited

weaverryan thank you very much for answer. Didn't noticed that you answered.
You understood correctly related to "group by".

I will answer here the solution that I found and maybe you or other people will say if this solution is the best one.

As I said, the data that I get is totally different from the entity because I use some aggregated functions in the query, I make some calculation like average and so on.

My solution was to make custom collection after this I added also custom output DTO and custom data provider.
In my custom data provider I'm applying all extensions of api platform in order to not lose basic functionality.

After getting response from repository I'm transforming manually the data into my output DTO, so my data provider return array of DTO.

PS. I thought that if my provider will return array of arrays, I will be able to use standard data transformer of api platform but it's not triggered, so for this reason I made transformation inside the data provider.

PS. related to data transformer, I think that the problem is that method getCollection don't return array of entity.

Reply

Hey Alex H.!

Nice work! That's... pretty complex - but it's a complex solution - thank you for sharing it :).

Cheers!

Reply
Alex H. Avatar

Between, if you or someone else have better solution and not so complex, please share with me. I'm sure that many people searching for this.

Reply
Paul A. Avatar
Paul A. Avatar Paul A. | posted 2 years ago

Anybody already tried to inject the pagination extension? I can't find any example in which the data is retrieved by the collectionDataProvider->getCollection() instead of the queryBuilder. Any suggestions?

Reply

Yo @Arthur!

Are you building a data provider that ultimately pulls from Doctrine? Or is it a completely custom data provider that gets the data from somewhere else? Usually, if I just want to modify how the data is pulled for a Doctrine entity, I'll create a custom data provider, but then (once I'm done with my modifications) call the existing data provider system so that all the normal magic (like pagination, etc) is done - so this approach https://symfonycasts.com/sc...

I know the API Platform docs talk about injecting the "extensions" into your custom data provider - https://api-platform.com/do... - but I've never had to do that. What is your use-case? And can you clarify what you mean by:

> I can't find any example in which the data is retrieved by the collectionDataProvider->getCollection() instead of the queryBuilder.

Cheers!

Reply
Paul A. Avatar

I solved everything with doctrine extensions now. Don't know the best practise? Provider or extension? But for me it works great now.

Reply

Hey @Arthur!

Excellent! I would say that the best practice is to use an extension when you're able to because. This is a "smaller" hook: you're letting API Platform do its custom logic, and you simply modify the query right in the middle of it. The only time I would use a full custom provider is if I need to do something *beyond* modifying the query - e.g. I'm pulling data from somewhere *other* than the database... or perhaps I want to fetch the objects and then call some custom method on them to populate an extra field of data. But even in this last case, I would still call the core, Doctrine entity data provider from my custom data provider so that it does all the heavy lifting for me :).

Cheers!

Reply
Paul A. Avatar
Paul A. Avatar Paul A. | weaverryan | posted 2 years ago | edited

He weaverryan!

Thanks for your fast reply. Although I'm using the dataprovider like in the 'Leveraging the Doctrine Data Provider'-course I loose al extra hydra data like pagination, total items etc. Also I'm not able to use filters in these entities.

In the api-platform injecting extensions example they do not use the collectionDataProvider getCollection method but build a query using the query builder. That's ok, but I prefer to use the platform logic.

Well all I want is collecting data with hydra information included and the possibility to use filters with these entities :) Do you have some magic for that?

Thanks!

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