api-platform / api-platform

Create REST and GraphQL APIs, scaffold Jamstack webapps, stream changes in real-time.
https://api-platform.com
MIT License
8.38k stars 947 forks source link

Updating a collection from a Symfony functional test #1689

Open hhamon opened 3 years ago

hhamon commented 3 years ago

API Platform version(s) affected: 2.5.7 (on Symfony 5.2.0-RC1)

Description
When updating the collection of related entities using a PUT operation on a single item from a Symfony functional test case, the API returns a 400 Bad Request response with error Item not found for "/api/accounts/4" while it works from the HTML Web interface.

How to reproduce
Consider the simplified Doctrine ORM entity mapping.

A User entity maintains a list of related Account objects (0 or more accounts). The Account entity is also exposed as a REST resource using the @ApiResource annotation.

<?php

/**
 * ...
 * @ApiResource(
 *     ...
 *     itemOperations={
 *         ...
 *         "put"={
 *             "security"="is_granted('edit', object)",
 *             "openapi_context"={
 *                 "description"="Updates the user profile matching the given ID. You can also provide `""me""` as the ID to get the currently-authenticated user.",
 *             },
 *         },
 *     },
 *     normalizationContext={"groups"={"read", "user_read"}},
 *     denormalizationContext={"groups"={"write", "user_write"}},
 * )
 */
class User
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer", options={"unsigned": true})
     * @ORM\GeneratedValue
     *
     * @Groups({"read"})
     */
    protected ?int $id = null;

    /**
     * @var Collection<int, AccountInterface>
     *
     * @ORM\ManyToMany(
     *   targetEntity=Account::class,
     *   inversedBy="usersFavorite",
     *   cascade={"persist"}
     * )
     * @ORM\JoinTable(name="user_favorite_account")
     *
     * @ApiSubresource
     *
     * @Groups({"user_read", "user_write"})
     */
    private Collection $favoriteAccounts;

    public function __construct()
    {
        $this->favoriteAccounts = new ArrayCollection();
    }

    public function addFavoriteAccount(Account $account): void
    {
        if ($this->favoriteAccounts->contains($account)) {
            return;
        }

        $this->favoriteAccounts->add($account);
        $account->addUsersFavorite($this);
    }

    public function removeFavoriteAccount(Account $account): void
    {
        if (! $this->favoriteAccounts->contains($account)) {
            return;
        }

        $this->favoriteAccounts->removeElement($account);
        $account->removeUsersFavorite($this);
    }
}

Using the API, we want the authenticated User to be able to favorite or unfavorite Account resources.

The following HTTP requests work through the HTML Web interface:

# Favoriting first account
curl -X PUT "http://localhost:8080/api/users/me" -H  "accept: application/ld+json" -H  "Content-Type: application/ld+json" -d "{\"favoriteAccounts\":[\"/api/accounts/5603\"]}"

# Favoriting second account
curl -X PUT "http://localhost:8080/api/users/me" -H  "accept: application/ld+json" -H  "Content-Type: application/ld+json" -d "{\"favoriteAccounts\":[\"/api/accounts/5603\",\"/api/accounts/6026\"]}"

# Unfavoriting first account
curl -X PUT "http://localhost:8080/api/users/me" -H  "accept: application/ld+json" -H  "Content-Type: application/ld+json" -d "{\"favoriteAccounts\":[\"/api/accounts/6026\"]}"

The collection of favorite accounts is correctly updated and API Platform successfully invokes the addFavoriteAccount and removeFavoriteAccount methods of my User entity that is being updated.

Not that the JSON payload only updates the favoriteAccounts collection. I'm not updating any other properties / attributes of my User resource.

Then I translate the above requests to a Symfony Web test case as follow:

<?php

final class UserAPITest extends AbstractAPITestCase
{
    // ...

    public function testUpdateMyListOfFavoriteAccountsWhenLoggedIn(): void
    {
        $client = static::createClient();

        $user = $this->findUserByEmail('salespro@domain.com');
        $client->loginUser($user);

    // The user from the loaded test fixtures already has a favorite account
        $this->assertCount(1, $user->getFavoriteAccounts());

    // Converting the list of favorite accounts to IRIs
        $favoriteAccounts = [];
        foreach ($user->getFavoriteAccounts() as $favoriteAccount) {
            $favoriteAccounts[] = $this->findIri($favoriteAccount);
        }

        // Add "OMHS" account to favorite accounts
        $data['favoriteAccounts']   = $favoriteAccounts;
        $data['favoriteAccounts'][] = $omhsAccount = $this->findIri($this->findAccountByCode('OMHS'));

        $response = $this->makeJsonRequest($client, 'PUT', '/api/users/me', \json_encode($data));

        $this->assertResponseIsSuccessful(); // <-- Response fails with a 400 Bad Request
    }
}

This is the request the Symfony Client executes:

PUT /api/users/me HTTP/1.1
Accept:          application/ld+json
Accept-Charset:  ISO-8859-1,utf-8;q=0.7,*;q=0.7
Accept-Language: en-us,en;q=0.5
Content-Type:    application/ld+json
Host:            localhost
User-Agent:      Symfony BrowserKit
X-Php-Ob-Level:  1
Cookie: MOCKSESSID=8317f720bbfd4f3f0631583758fc8b945ed7485d39719b1539cad014f9101417

{"favoriteAccounts":["\/api\/accounts\/1","\/api\/accounts\/4"]}

And this is an extract of the beautified JSON response:

{
    "@context": "\/api\/contexts\/Error",
    "@type": "hydra:Error",
    "hydra:title": "An error occurred",
    "hydra:description": "Item not found for \u0022\/api\/accounts\/4\u0022.",
    "trace": [
        {
            "namespace": "",
            "short_class": "",
            "class": "",
            "type": "",
            "function": "",
            "file": "\/var\/www\/vendor\/api-platform\/core\/src\/Serializer\/AbstractItemNormalizer.php",
            "line": 421,
            "args": []
        },
        {
            "namespace": "ApiPlatform\\Core\\Serializer",
            "short_class": "AbstractItemNormalizer",
            "class": "ApiPlatform\\Core\\Serializer\\AbstractItemNormalizer",
            "type": "-\u003E",
            "function": "denormalizeRelation",
            "file": "\/var\/www\/vendor\/api-platform\/core\/src\/Serializer\/AbstractItemNormalizer.php",
            "line": 397,
            "args": [
                [
                    "string",
                    "favoriteAccounts"
                ],
                [
                    "object",
                    "ApiPlatform\\Core\\Metadata\\Property\\PropertyMetadata"
                ],
                [
                    "string",
                    "App\\Entity\\Account\\Account"
                ],
                [
                    "string",
                    "\/api\/accounts\/4"
                ],
                [
                    "string",
                    "jsonld"
                ],
                [
                    "array",
                    {
                        "groups": [
                            "array",
                            [
                                [
                                    "string",
                                    "write"
                                ],
                                [
                                    "string",
                                    "user_write"
                                ]
                            ]
                        ],
                        "operation_type": [
                            "string",
                            "item"
                        ],
                        "item_operation_name": [
                            "string",
                            "put"
                        ],
                        "api_allow_update": [
                            "boolean",
                            true
                        ],
                        "resource_class": [
                            "string",
                            "App\\Entity\\Account\\Account"
                        ],
                        "input": [
                            "null",
                            null
                        ],
                        "output": [
                            "null",
                            null
                        ],
                        "request_uri": [
                            "string",
                            "\/api\/users\/me"
                        ],
                        "uri": [
                            "string",
                            "http:\/\/localhost\/api\/users\/me"
                        ],
                        "api_denormalize": [
                            "boolean",
                            true
                        ],
                        "cache_key": [
                            "string",
                            "0e67a518eba773aa8a82ae50e92c848d"
                        ]
                    }
                ]
            ]
        },
       ...
     ]
   }
}

Full stack trace can be found here: https://gist.github.com/hhamon/fabb2b268979b4118eeaedf6649ad6e7

Why would the API behaves differently from the Web interface (Symfony dev environment) and the functional tests (Symfony test environment). I didn't override any api_platform config or classes for a specific environment.

hhamon commented 3 years ago

@dunglas would you have a hint on this one please?

desnudopenguino commented 3 years ago

i'm having a similar issue POSTing a new entity, then trying to get it to build another one.

viszman commented 3 years ago

I have the same issue as @desnudopenguino