Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Completely Custom Resource

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

Even though they have some custom fields, both of our API resources are truly bound to the database. Ultimately we're querying for and saving to a specific entity like CheeseListing or User.

Custom Fields vs Custom Resource Class

But really, an API resource can be any object that pulls data from... anywhere! So instead of adding un-persisted $isMe and $isMvp fields to User:

... lines 1 - 41
class User implements UserInterface
{
... lines 44 - 97
/**
* Returns true if this is the currently-authenticated user
*
* @Groups({"user:read"})
*/
private $isMe = false;
/**
* Returns true if this user is an MVP
*
* @Groups({"user:read"})
*/
private $isMvp = false;
... lines 111 - 286
}

We could have created a totally different non-entity class - like UserApiResource - with exactly the fields in our API.

And, the way we would do that is pretty similar to what we've seen: we would need a custom data provider and data persister. The reason we didn't do that is that if your underlying data source is a Doctrine entity, it's much easier to add a few custom fields than to reinvent the wheel with a custom resource. After all, with a Doctrine entity, we get things like pagination and filtering for free!

But sometimes... you will need to create a completely custom ApiResource class, maybe because the data comes from some complex queries that join across multiple tables... or maybe the data doesn't come from the database at all!

So here's our goal: we're going to create a new DailyStats API resource so that we can have an endpoint - /api/daily-stats - that returns a collection of items where each one contains site stats for a single day: things like total visitors and most popular cheese listings. And we're going to pretend that the data for these stats do not come from the database.

Creating the API Resource Class

So... let's get started! Step one: create an API resource class that looks exactly how we want our API to look... regardless of how the underlying data looks or where it's coming from.

In the Entity/ directory, create a new class called DailyStats:

... lines 1 - 2
namespace App\Entity;
... lines 4 - 9
class DailyStats
{
... lines 12 - 16
}

And yes, I'm putting this in the Entity/ directory even though this isn't going to be a Doctrine entity. That's totally allowed and it's up to you if you like this or not. If you do want to put API Resource classes somewhere else, then in config/packages/api_platform.yaml, you'll need to tweak the config to tell API Platform to look in that new spot:

api_platform:
mapping:
paths: ['%kernel.project_dir%/src/Entity']
... lines 4 - 21

Easy peasy!

Back in the new class, for simplicity - and... because this class will stay very simple - I'm going to use public properties: public $date, public $totalVisitors and a public $mostPopularListings that will hold an array of CheeseListing objects:

... lines 1 - 9
class DailyStats
{
public $date;
public $totalVisitors;
public $mostPopularListings;
}

If you're using PHP 7.4, you can also add property types, which will also help API Platform's documentation. More on that soon. Or, if you don't want this stuff to be public, use the normal private properties with getters and setters: whatever you want.

To officially make this an API Resource class, above, add @ApiResource:

... lines 1 - 4
use ApiPlatform\Core\Annotation\ApiResource;
/**
* @ApiResource()
*/
class DailyStats
{
... lines 12 - 16
}

That's it! Spin back to the browser and refresh the documentation. Say hello to our DailyStats resource! No... it won't magically work yet, but it is already documented.

Customizing the Resource URL

Now, I don't mean to be picky, but I don't love this daily_stats URL - dashes are much more hipster. We can fix that by adding an option to the annotation: shortName="daily-stats":

... lines 1 - 6
/**
* @ApiResource(
* shortName="daily-stats"
* )
*/
class DailyStats
{
... lines 14 - 18
}

When we refresh the docs now... that did it! But we could have also solved this "Ryan hates underscores" problem in a more global way. Search for "API Platform operation path naming" to find a spot on their docs that talks about how the URLs are generated.

Whenever you have an entity - like DailyStats - there is a process inside API Platform that converts the word DailyStats to daily_stats or daily-stats. That's called the path_segment_name_generator and it's something you can customize. It uses underscores by default, but there's a pre-built service that loves dashes.

Copy the example config. Then, remove the shortName option and in api_platform.yaml paste that config anywhere:

api_platform:
... lines 2 - 5
path_segment_name_generator: api_platform.path_segment_name_generator.dash
... lines 7 - 22

Back on our docs, let's try it! Beautiful! The URL is still /api/daily-stats.

Next: let's make these operations actually work, starting with the get collection operation. How do we do that? By adding a data provider... and a few other details that are special when you working with a class that is not an entity.

Leave a comment!

15
Login or Register to join the conversation
Alexander-S Avatar
Alexander-S Avatar Alexander-S | posted 9 months ago | edited

If you were creating this tutorial on custom resources for api-platform version 2.7 would you change anything significant? I'm asking since I'm currently trying to upgrade an (fully working) app from php7.4/symfony4.4/api-platform2.6 to php8.1/symfony5.4/api-platform2.7 and all my routes work except for the custom routes where I get a 404 not found exception. Following these videos I think I have everything necessary in the various places (and I also used the automatic upgrade script provided by api-platform) but I'm left scratching my head as to why the routes can't be found. Many thanks!

A little more info: changing

/**
 * @ApiResource(
 *   collectionOperations={
 *     "get_details"={
 *       "method"="GET",
 *       "path"="/details",
 *       "controller"=DetailsController::class,
 *       "normalization_context"={"groups"={"mock"}},
 *     }
 *   },
 *   itemOperations={"get"}
 * )
 */

to

#[ApiResource(operations: [new Get(), new GetCollection(uriTemplate: '/details', controller: DetailsController::class, normalizationContext: ['groups' => ['mock']])])]

makes the difference between the route being found or not, with the annotation version being the working one and the attribute one being the non-working version. In order for this to work I need to have metadata_backward_compatibility_layer: true set in api_platform.yaml

Reply

Hey Alexander!

Sorry for the slow reply - holidays! To be honest, I'm not sure yet. I'm going to be working on the API Platform 3 tutorial over the next few weeks, and 2.7 and 3.0 are, I believe, effectively identical from a feature point of view.

In theory, going from 2.6 -> 2.7 should not cause a problem, as 2.7 doesn't break backwards-compatibility. But, API Platform is complex, so it's possible some minor, accidental changes occurred. I was about to mention metadata_backward_compatibility_layer, but it seems that you found that setting! That setting is fine for now, but you'll need to set it to false before upgrading to API Platform 3. What's missing to allow you to set it to false and still see your route? Unfortunately, I can't answer that yet :/.

Sorry I can't give a better answer - my knowledge isn't "there" yet in this case :).

Cheers!

Reply
Alexander-S Avatar

Sorry I forgot to delete my comment, I solved my issue. Don't ask how because I have no idea, but now it works and before it didn't :) Hope you enjoyed the holidays!

Reply

Haha, no worries! Glad it's working :D

Reply
Emanuele-P Avatar
Emanuele-P Avatar Emanuele-P | posted 1 year ago | edited

hallo,
i try to find a way to use LazyString as at 1:51,
so I create a property fakeName in user (the property is not in the serializazion group for the collection)


private ?string $fakeName = null;
public function getFakeName() : ?string
{
  return $this->fakeName;
}
public function setFakeName(?\Stringable $fakeName) : User
{
    $this->fakeName = $fakeName;
    return $this;
}

than in postLaod event i have done this


$bio = LazyString::fromCallable([$this, 'callback']
);
$user->setFakeName($bio);

and than i create the method itself


public function callback()
{
    dump('here i m');
    return 'ffffff';
}

but when i run http://localhost/api/users
all the dumps appears
so maybe i m not using it correctly?
can you suggest me a better way?

Reply
Bernard A. Avatar
Bernard A. Avatar Bernard A. | posted 1 year ago

I am finding a couple of issues when applying this to graphql. I will report where I believe appropriate.

It seems that naming an Entity, or at least an ApiResource, with a name in plural is not a good idea as far as graphql is concerned.

When I enabled graphql I could not find the collection query for DailyStats. After I checked all else, I attempted to change the name of the Entity to DailyStat and voila! the collection query was where it shoud be. Of course, I changed all else from DailyStats to DailyStat.

API-Platform by default seem to assume that ApiResource name are singular and automatically pluralizes it to get the name of the collection query. As it got queries with the same name, it dropped one of them. Or so I believe.

Reply

Hey Bernard A.!

> I am finding a couple of issues when applying this to graphql. I will report where I believe appropriate.

Happy to have them :).

> When I enabled graphql I could not find the collection query for DailyStats. After I checked all else, I attempted to change the name of the Entity to DailyStat and voila! the collection query was where it shoud be. Of course, I changed all else from DailyStats to DailyStat.

Ah, bad pluralization! That's my fault. In English, we don't even think of "statistics" as plural, but it absolutely is. DailyStat would, indeed, be a better name.

Cheers!

1 Reply
André P. Avatar
André P. Avatar André P. | posted 2 years ago | edited

Hey, everyone!

It seems that, at least in API Platform 2.6.5, adding @ApiResource() is not enough, since it requires to explicitly mark the identifier for the resource. So, you'll get an error like this:

No identifier defined in "App\Entity\DailyStats". You should add #[\ApiPlatform\Core\Annotation\ApiProperty(identifier: true)]" on the property identifying the resource.

By adding the following @ApiProperty(identifier=true) to the property that you want to uniquely identify the resource, the problem is solved.

In this case I added to the date property, since we're talking about <b>daily</b> stats:

`/**

  • @ApiProperty(identifier=true)
    */
    public $date;
    `

Hope it helps!

Cheers!

Reply

Hey André P.

You're right, defining the ApiPlatform identifier is required when you're not working on an Entity, in other words, when working with a DTO

Cheers!

1 Reply
André P. Avatar

Thank you, Diego.

Just realized that in the next chapter this ends up being dealt with, but it may help someone who gets stuck with that error on this one.

Cheers!

1 Reply

How can I Unit Test the API in a stand alone Symfony Bundle?

Reply

Hey M_Holstein !

That's an excellent question! And the answer has a lot more to do with "how do I functionally test a bundle" than it does with API Platform. Here is an example from another tutorial - https://symfonycasts.com/sc... - it does not include API Platform (so you will need to add that as bundle to the kernel... and there may be some other changes), but I hope it will point you in the right direction :).

Cheers!

Reply
mega Avatar

Hello.

I have added a completely new Custom Resource, which gets data from an external service. Now I want to show this resource on another Entity based on its @id. How can I do that? I tried creating a new CustomResource($id) object but it doesn't go through the CustomDataProvider I wrote for it.
Thanks

Reply

Hi mega!

That's an interesting question! You're right about the data provider: the data provider is only execute for whatever the top level component is. So, for example, if the user goes to /api/products/5, then the data provider for this "Product" entity is executed. Suppose you want that to return a new "customResource" property that contains your CustomResource. If you add that property to Product, all API Platform will do is call $product->getCustomResource() to get that property (no data provider for CustomResource is involved, as you saw).

The way to solve this is to add a custom data provider for your Product entity. This would probably decorate the normal Doctrine data provider, but then it would populate the customResource property on Product. So, it's a bit laborious - but that's the idea.

Let me know if that helps!

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