Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

App & Test Setup

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Hey friends. Yea! It's time for part 3 of our API Platform tutorial series. First, let's do a status check, because we have been busy!

In part 1, we got everything we needed for a pretty sweet API. We talked about JSON-LD and OpenAPI, operations and config, serialization groups, validation, relations, IRIs, filtering and more.

In part 2, we talked about security, logging in, adding authorization checks to operations, making it so that certain fields can be read or written only by specific users and some pretty serious work related to custom normalizers for even more control over exactly which fields each user sees.

So what's in part 3? It's time to take customizations to the next level, like making it possible to publish an item and run code when that happens... kind of like a custom "publish" operation... but with a RESTful twist. We'll also add complex security rules around who can publish an item under different conditions. Then we'll do everything custom: add completely custom fields, completely custom API resources that aren't backed by Doctrine, custom filters and we'll even dive deep into API Platform's input and output DTO system. Whoa.

To be honest, this was not an easy tutorial to write. Not because it was hard to figure out how to make these customizations - though, some of this is tricky - but because we're getting so deep into API Platform, that sometimes there are multiple ways to accomplish something. We'll find the best paths and learn why. Oh, and a huge thanks to Kévin Dunglas and Antoine Bluchet - core API platform developers - who helped me a ton to find those best paths.

Project Setup

So... let's go! To POST the most new data to your brain, you should totally code along with me! Download the course code from this page and, after unzipping it, you'll find a start/ directory with the same code that you see here. Pop open up the README.md file for all the details about getting this project working on your machine.

Now, it's optional, but, for this tutorial, I'm using a special Docker and symfony binary integration to spin up and configure my database. The details are in the README and you can learn more about it in our Symfony Doctrine Tutorial.

Anyways, one of the steps in the README is to find a terminal, move into the project and run:

symfony serve -d

to start a dev web server at https://127.0.0.1:8000. Copy that URL, find your browser and say hello to... CheeseWhiz! Our peer-to-peer cheese selling site where the world can find - and purchase - the cheesy treasures that you forgot were in your fridge.

Our site does have a tiny frontend - this is build in Vue.js - but mostly it's just for logging in. The vast majority of the site is an API. Go to /api to see the interactive documentation generated by Swagger.

Updates since Episode 2

Now, if you coded along with me in episodes one and two, first: you rock, and second, this is the same app, but with some changes... so make sure to download the latest code. First, we upgraded our dependencies to use Symfony 5.1 and API platform 2.5:

79 lines composer.json
{
... lines 2 - 3
"require": {
... lines 5 - 7
"api-platform/core": "^2.1",
... lines 9 - 16
"symfony/asset": "5.1.*",
"symfony/console": "5.1.*",
"symfony/dotenv": "5.1.*",
"symfony/expression-language": "5.1.*",
"symfony/flex": "^1.1",
"symfony/framework-bundle": "5.1.*",
"symfony/http-client": "5.1.*",
"symfony/monolog-bundle": "^3.4",
"symfony/security-bundle": "5.1.*",
"symfony/twig-bundle": "5.1.*",
"symfony/validator": "5.1.*",
"symfony/webpack-encore-bundle": "^1.6",
"symfony/yaml": "5.1.*",
... line 30
},
... lines 32 - 71
"extra": {
"symfony": {
... line 74
"require": "5.1.*"
}
}
}

I also added Foundry to our system, which is a new library that makes it really easy to create dummy data:

79 lines composer.json
{
... lines 2 - 3
"require": {
... lines 5 - 29
"zenstruck/foundry": "^1.1"
},
... lines 32 - 77
}

We're using Foundry inside of our data fixtures - so src/DataFixtures/AppFixtures.php - as a quick way to create a bunch of users and cheese listings:

... lines 1 - 12
class AppFixtures extends Fixture
{
... lines 15 - 21
public function load(ObjectManager $manager)
{
$user = UserFactory::new()->create([
'email' => 'cheesefan@example.com',
'username' => 'cheesefan',
'password' => $this->passwordEncoder->encodePassword(new User(), 'cheese'),
]);
UserFactory::new()->createMany(50);
$listingFactory = CheeseListingFactory::new([
'owner' => $user,
])
->published();
$listingFactory->create([
'title' => 'Mysterious munster',
'description' => 'Origin date: unknown. Actual origin... also unknown.',
'price' => 1500,
]);
$listingFactory->create([
'title' => 'Block of cheddar the size of your face!',
'description' => 'When I drive it to your house, it will sit in the passenger seat of my car.',
'price' => 5000,
]);
// then create 30 more
$listingFactory->createMany(50);
}
}

We also use this inside our test suite. You don't need to know too much about Foundry, but if you're interested, check out our Symfony Doctrine tutorial for more details.

Running the Tests

Now, before we start, we already have a test suite in our project with some basic functional tests for our 2 API resources: CheeseListing:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
public function testCreateCheeseListing()
{
... lines 14 - 43
}
public function testUpdateCheeseListing()
{
... lines 48 - 67
}
public function testGetCheeseListingCollection()
{
... lines 72 - 102
}
public function testGetCheeseListingItem()
{
... lines 107 - 119
}
}

and User:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
public function testCreateUser()
{
... lines 13 - 24
}
public function testUpdateUser()
{
... lines 29 - 45
}
public function testGetUser()
{
... lines 50 - 72
}
}

We're going to rely on tests a lot in this tutorial... in part because, as cool as this interactive documentation is - and we will use it - it gets a bit tedious to constantly fill out the boxes to test manually. And, of course, having tests will guarantee our API doesn't break.

So before we start breaking things on purpose, let's make sure the tests pass. At your terminal, run:

symfony run bin/phpunit

Tip

Using symfony php bin/phpunit is even better because it can intelligently find your PHP executable.

First, symfony run is just a shortcut to execute PHP. So this mean:

php bin/phpunit

The difference is that symfony run will inject the Docker environment variables, which will give my script access to the database.

Anyways, the first time you run this, it will download PHPUnit in the background... and once it finishes... boom! At the bottom, it executes our tests and they are working. We have permission to start breaking things!

The Database Test Config

By the way, our tests - of course - run in the test environment... and I do have a little bit of special configuration for the test environment in this app. Open up config/packages/test/ - so the files that are only loaded in the test environment - /doctrine.yaml:

# config/packages/test/doctrine.yaml
doctrine:
dbal:
url: '%env(resolve:DATABASE_TEST_URL)%'

I've overridden the database config to read an environment variable called DATABASE_TEST_URL. If you're using the Docker Integration that I describe in the README, then this environment variable is being automatically exposed. If you look in docker-compose.yaml, there are two database containers: database and database_test:

version: '3.7'
services:
database:
... lines 4 - 13
database_test:
... lines 15 - 20

Thanks to this, the symfony binary exposes a DATABASE_URL environment variable that points to the first container and a DATABASE_TEST_URL env var that points to the second.

In other words, in the test environment, we're using the database_test container:

... line 1
doctrine:
dbal:
url: '%env(resolve:DATABASE_TEST_URL)%'

And, if you open the main doctrine.yaml, in the other environments, we're reading DATABASE_URL, which points to the first container:

doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
... lines 4 - 19

That's a long way of saying that our tests use a different container than our dev environment. If you're not using this same Docker setup, feel free to customize things. In .env.test, I am setting DATABASE_TEST_URL to DATABASE_URL:

7 lines .env.test
... lines 1 - 4
DATABASE_TEST_URL=$DATABASE_URL
... lines 6 - 7

Which means that it falls back to using your normal connection config. Feel free to tweak any of these files and let us know if you have questions.

Next, one of the things that we did in the last course was create a custom data persister: UserDataPersister:

... lines 1 - 9
class UserDataPersister implements DataPersisterInterface
{
... lines 12 - 20
public function supports($data): bool
{
return $data instanceof User;
}
/**
* @param User $data
*/
public function persist($data)
{
if ($data->getPlainPassword()) {
$data->setPassword(
$this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
);
$data->eraseCredentials();
}
$this->entityManager->persist($data);
$this->entityManager->flush();
}
public function remove($data)
{
$this->entityManager->remove($data);
$this->entityManager->flush();
}
}

Let's see why we did this, use decoration to make it more powerful and learn how data persisters are different than some other parts of ApiPlatform, like context builders.

Leave a comment!

29
Login or Register to join the conversation

Looks like base.html.twig is missing from the zip file. I restored it from the previous tutorial, but you might wanna look into it.

Cheers!

1 Reply

Hey julien_bonnier!

Ah, thank you for mentioning this! We have a process that builds these automatically.... but it looks like a recent code tweak we made for this tutorial made things go wacko. I've just reverted the bad commit. Please let me know if you notice any other problems!

Thanks!

Reply
Bernard A. Avatar
Bernard A. Avatar Bernard A. | posted 6 months ago

Hi

I am getting an error

SQLSTATE[HY000] [1049] Unknown database 'root'

when trying to create an user on
localhost:8000/api.

Only thing I am aware I changed on the start code was to modify Mysql PORT as below, as I was getting error

port already used for port 3306

:

Oh, I also had to change the platform: linux/amd64 below for apple M1.


database:
image: "mysql:5.7"
platform: linux/amd64
environment:
MYSQL_ROOT_PASSWORD: password
ports: - "3306:3306"

database_test:
image: "mysql:5.7"
platform: linux/amd64
environment:
MYSQL_ROOT_PASSWORD: password
ports:
- "3306"

This is a portion of the error printout. I did not include all of it because it's really a pain to do this on Disqus.


"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/Users/bernardo/Sites/tutorials/code-api-platform-extending/start/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractMySQLDriver.php",
"line": 112,
"args": []
},

Also I noticed that later ( Chapter 11, I believe ) you mentioned that there are 51 users on the DB, but I could not login at all, due to same error.

Reply

Hey Bernard A.

I believe you just forgot to initialize your database. You can rely on Doctrine commands to do so.


symfony console doctrine:database:create
symfony console doctrine:schema:create
symfony console doctrine:fixtures:load

Cheers!

Reply
Bernard A. Avatar

That was that! Many thanks!

Reply

Heythere ,

I booted from a full installation of symfony 5.3.9 and ApiPlatform 2.6. I have problem with MySql database installation.

It's ok for the fixtures and displaying the data in the API.

But when I run the test --filter=testCreateCheeselisting, there is this error : {"error": "Invalid login request: check that the Content-Type header is \ u0022application \ / json \ u0022."}

I try to add

'headers' => [
'Content-Type' => 'application/json',
]

but no change.

Reply

Hey Stephane!

Hmmm. Ok, so this means that, somehow, our "login()" controller is being executed: https://symfonycasts.com/sc... without authentication happening.

That shouldn't happen (in theory). Here is what the flow *should* look like:

A) We make the request to /login with the login credentials
B) This request is intercepted by the json_login "authentication listener".
C) If authentication is successful, the request WILL hit our "login()" controller, but we will be authenticated. The fact that you're getting that error means that you are NOT authenticated when you get to the controller.
D) Or if authentication is NOT successful, an error response will be returned and the login() controller will never be executed.

So it seems to me that, for some reason, the json_login authentication listener is *not* doing it's work. Here are a few things to check:

1) Make sure you have the json_login key activated under your firewall: https://symfonycasts.com/sc...

2) If it *is*, then I would put some temporary debugging code into that core class to see what's going on: https://github.com/symfony/... - make sure this is being executed and returning true and make sure that https://github.com/symfony/... is being executed. I'm assuming you're using the "old" security system - if you're using the new "authenticator" system, then the class you need to debug in is called "JsonLoginAuthenticator".

Let me know what you find out :).

Cheers!

Reply

Hey weaverryan ,

Shame on me, I forgot the firewall parameters.

So, the authentication pass but I have error array_unique(): Argument #1 ($array) must be of type array, string given into src/Serializer/AdminGroupsContextBuilder.php (line 28)

I try to fix with


if (is_string($context['groups'])) {
$context['groups'] = [$context['groups']];
}


but after it's created an infinite loop.

Reply

Hey Stephane!

Hmm. The error:

> array_unique(): Argument #1 ($array) must be of type array, string given into src/Serializer/AdminGroupsContextBuilder.php (line 28)

Tells me that, for some reason, line 28 of this code block https://symfonycasts.com/sc... (if you expand it) is a "string" instead of an array. This seems like the same issue that we were talking about on the other thread - https://symfonycasts.com/sc... - somehow your groups are set to a "string" instead of an array. But, your fix makes sense: if it's a string, you code defensively. So I don't see a problem with that :).

> but after it's created an infinite loop.

This IS a problem of course :). What does the loop look like? I mean, which method(s) are called over and over again? My guess is that you have some sort of recursion in the "normalizer" system - that's the easiest place to get it. We talk about the normalizer "recursion" problem in this chapter - https://symfonycasts.com/sc... (and we explain the internals of how normalizers were in the chapter before it). It's just a guess that your infinite loop is due to the normalizer system, but if it is, check out that section.

Cheers!

Reply
Kiuega Avatar

Hello ! Why do we no longer have the context generator we created in part 2?

Reply

Hey Kiuega!

Are you referring to the AutoGroupResourceMetadataFactory? If so, I removed it *just* to simplify the understanding for this tutorial: I didn't want someone to be unsure why some field was showing up due to the "auto groups" functionality... which isn't the focus of this tutorial. So I eliminated that variable and was more explicit. There was no bigger reason - like it being a "bad idea" or anything like that :).

Cheers!

1 Reply
Romain L. Avatar
Romain L. Avatar Romain L. | posted 1 year ago

Hello,

Not sure if it's best here or in the part 2, but i'll take my chances here. While my tests were working before, since i upgraded Api Platform to 2.5.9 (i think it's this one), it's not working anymore about authentification.

I tested 2 ways to create a User (one way with Foundry like it was before, and another way with the regular createUser method available in your tutorial):
$authenticatedUser = UserFactory::new()->create();
$authenticatedUser = $this->createUser('test@test.com','testing');

I have the same logIn method as yours:


$client->request('POST', '/login', [
'json' => ['email' => $email,
'password' => $password],
]);
$this->assertResponseStatusCodeSame(200);

This assertion is working fine. Then i want to post something to my entity which has:


"post" = {"security"="is_granted('ROLE_USER')"}

but i receive a 401 status instead of a 201.

If i dump my $authenticatedUser i can see that i have a User object. i tried to dump in my prePersist EntityListener or in my dataPersister but the execution does not even goes until there.

I don't understand why it's not working anymore. and Of course, in dev or in prod it's working fine.
I upgraded to PHP 8, Api Platform 2.6 beta, the new security system and phpUnit 9.5 but it doesn't change anything.

Any idea about that?

Thanks a lot

EDIT: here the logs from the test: actually it seems to come from the security:


security.INFO: Authenticator successful!
security.DEBUG: Remember-me was not requested. [] []
security.DEBUG: The "App\Security\LoginAuthenticator" authenticator set the response.
security.DEBUG: Stored the security token in the session. {"key":"_security_main"} []
...
request.INFO: Matched route "api_myEntity_post_collection".
security.DEBUG: Checking for authenticator support. {"firewall_name":"main","authenticators":2} []
security.DEBUG: Checking support on authenticator. {"firewall_name":"main","authenticator":"App\\Security\\LoginAuthenticator"} []
security.DEBUG: Authenticator does not support the request. {"firewall_name":"main","authenticator":"App\\Security\\LoginAuthenticator"} []
security.DEBUG: Checking support on authenticator. {"firewall_name":"main","authenticator":"Symfony\\Component\\Security\\Http\\Authenticator\\RememberMeAuthenticator"} []
security.DEBUG: Authenticator does not support the request. {"firewall_name":"main","authenticator":"Symfony\\Component\\Security\\Http\\Authenticator\\RememberMeAuthenticator"} []
security.DEBUG: Access denied, the user is not fully authenticated; redirecting to authentication entry point.
security.NOTICE: No Authentication entry point configured, returning a 401 HTTP response. Configure "entry_point" on
request.ERROR: Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\HttpException: "Full authentication is required to access this resource."

when requesting the route on "api_myEntity_post_collection", he is not trying to fetch for the user, there is directly an access denied... isn't it weird?

Reply

Hey Romain L. !

This is some EXCELLENT, detailed reporting - nice job!

So.... there are a lot of moving pieces. At first, when I saw your logs, I thought you were hitting the "new security system doesn't work in API Platform until API Platform 2.6" bug/error... but then I saw that you *did* in fact upgrade to API Platform 2.6.

There is one thing that is mysteriously missing from the logs you posted. Because you are using session-based authentication, I would expect to see a log from the ContextListener: Symfony's core class that reloads the user from the session. Specifically, I would expect to see this:

> Read existing security token from the session

Coming from this class: https://github.com/symfony/...

But... I don't! Is your firewall stateful (meaning stateless: false or more likely you just don't have a stateless key)? It simply "seems" like your authentication is, in fact, *not* sticking across the session in your test. Is it working if you try it manually?

Many questions... not answer yet - sorry ;). But maybe this can help you move closer!

Cheers!

Reply
Romain L. Avatar

Hello Ryan,

and thank you so much for your answer, you totally rock because, thanks to you, i found the answer :D
Thanks for your hint about ContextListener because it makes me thinks that recently i moved from the default storage to PDO storage for session.... and that was the problem.

In my packages/framework.yaml i had:

session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
gc_divisor: 100
gc_maxlifetime: 7200
cookie_secure: auto
cookie_samesite: lax
cookie_domain: '%env(COOKIE_DOMAIN)%'

and in my test/framework.yaml i had the default configuration:

session:
storage_id: session.storage.mock_file

but that was not enough to make it work in test (due to cookie env conf and pdo conf in services i think) and i added this:

session:
storage_id: session.storage.mock_file
handler_id: session.handler.native_file
gc_maxlifetime: 1440
cookie_secure: auto
cookie_samesite: lax
cookie_domain: ''

and now it works perfectly in my test environment :)
Thank you so much and have a nice day

Reply

Woohoo! Nice work Romain L. and thanks for sharing the solution! That IS subtle!

Reply
Sebastian K. Avatar
Sebastian K. Avatar Sebastian K. | posted 1 year ago

Can you explain the part with the 2 docker containers? How are the two env vars (DATABASE_URL and DATABASE_TEST_URL) resolved? Are they generated from the names of the docker container?

Reply

Hey Sebastian K.

Yep it's symfony cli "magic". It reads your docker_compose.yaml and preconfigure env vars if they can be parsed!

Cheers!

Reply
Stefan T. Avatar
Stefan T. Avatar Stefan T. | posted 2 years ago

Why you just don't use SQLite for tutorials?

Reply

Hey Stefan T.

That's a good question. I'd say because it's more realistic using MySql than SQLite in a production environment

Cheers!

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

For those that are having trouble running the tests using

symfony run bin/phpunit

Use this instead

symfony php bin/phpunit

Reply

Hey Kibria4,

Thank you for this tip! Could you tell us what troubles do you have with the "symfony run" command? Any errors in the output? What OS are you on btw?

Cheers!

Reply
Kibria A. Avatar

Hi Victor!

When running the command used in the video, it gave me the error "exec: "bin/phpunit": file does not exist". The file was in the bin/ directory but was still getting that error. I then ran "php bin/phpunit" to install phpunit. Tried using the symfony binary again and it still didn't work.

Then I found this on Github, where Fabien mentioned using "symfony php" instead of "symfony run", and that worked!

I am using Windows 10 Pro, I think this may be a Windows-specific problem.

Cheers!

1 Reply

Yea, that's totally my fault - I'm using the "harder" command. I'll fix that going forward - thanks!

1 Reply
Kibria A. Avatar

Hi Ryan,

No problem at all, glad I was able to help!

Reply
Robin C. Avatar
Robin C. Avatar Robin C. | posted 2 years ago

So if I see this right zenstruck/foundry acts as a replacement for the hautelook/AliceBundle dependency which is currently incompatible with api-platform 2.5.7? And is there a reason why zenstruck/foundry and the doctrine-fixtures-bundle isn't just a require-dev dependency?

Reply

Yo Robin C.!

> So if I see this right zenstruck/foundry acts as a replacement for the hautelook/AliceBundle

💯 I have not been very happy with Alice for awhile now, and have been looking for replacements. Foundry even has (via a simple trait, similar to Alice) a way to initialize and reset the database between tests. I'm not familiar with the issue of Alice being incompatible with api-platform 2.5.7 - I dumped it because Foundry is better.

> And is there a reason why zenstruck/foundry and the doctrine-fixtures-bundle isn't just a require-dev dependency?

Ha! Yes. The reason is that I made a mistake 😂. Thanks for pointing that out - I'm currently moving both to require-dev. I do 1000 things when I create a tutorial, and apparently I got a bit lazy with my --dev flag on Composer.

Cheers!

1 Reply
Abdulkabir Avatar

Unfortunately, Alice isn't compatible with API Platform. I have tried to use with my test, though I was able to create fixtures but couldn't reload or refresh the database.

Reply
Robin C. Avatar
Robin C. Avatar Robin C. | weaverryan | posted 2 years ago | edited

Thank you weaverryan for the insight about the new dependency! And keep up the good work! I'm an absolute fan of the SymfonyCasts videos, you and the team do an amazing job. :)

Reply

Thank you ❤️

1 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": "^7.4.0 || ^8.0",
        "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
    }
}