core icon indicating copy to clipboard operation
core copied to clipboard

GraphQL operations return null for DTO collections while REST operations work correctly

Open lcottingham opened this issue 8 months ago • 7 comments

API Platform version(s) affected: 4.1.x

Description When using DTOs with collections in API Platform, there is inconsistent behavior between REST and GraphQL operations.

In REST operations, collections (both Doctrine Collections and arrays) within DTOs are automatically serialized correctly as arrays in the response.

However, in GraphQL operations, the same collections are automatically wrapped in a cursor-based pagination structure (edges/nodes) but the content is returned as null despite the collections containing data:

{
  "data": {
    "book": {
      "title": "Sample Book",
      "author": {
        "name": "John Doe"
      },
      "categories": {
        "edges": null
      }
    }
  }
}

Disabling pagination at the resource level fixes this issue by removing the cursor structure, but this should work out of the box like it does for REST operations, or at least be clearly documented how to handle this case.

How to reproduce

  1. Create a simple DTO structure:
<?php

namespace App\Dto;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\GraphQl\Query;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    shortName: 'Book',
    normalizationContext: ['groups' => ['book:read']],
    graphQlOperations: [
        new Query(resolver: BookResolver::class),
    ]
)]
class Book
{
    #[Groups(['book:read'])]
    public string $title;

    #[Groups(['book:read'])]
    public Author $author;

    #[Groups(['book:read'])]
    /** @var Collection<int, Category> */
    public Collection $categories;
}
<?php

namespace App\Dto;

use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(operations: [], graphQlOperations: [])]
class Author 
{
    #[Groups(['book:read'])]
    public string $name;
}
<?php 

namespace App\Dto;

use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(operations: [], graphQlOperations: [])]
class Category
{
    #[Groups(['book:read'])]
    public string $name;
}
  1. Create a resolver for GraphQL:
<?php

namespace App\GraphQl\Resolver;

use App\Dto\Author;
use App\Dto\Book;
use App\Dto\Category;
use Doctrine\Common\Collections\ArrayCollection;
use Psr\Log\LoggerInterface;

class BookResolver
{
    public function __construct(
        private LoggerInterface $logger
    ) {
    }

    public function __invoke(): Book
    {
        $book = new Book();
        $book->title = "Sample Book";

        $author = new Author();
        $author->name = "John Doe";
        $book->author = $author;

        // Create a collection with categories
        $categories = new ArrayCollection();
        
        $category1 = new Category();
        $category1->name = "Fiction";
        $categories->add($category1);
        
        $category2 = new Category();
        $category2->name = "Adventure";
        $categories->add($category2);
        
        $book->categories = $categories;

        // Log to demonstrate the collection has content
        $this->logger->info('Categories count: ' . $categories->count());
        
        return $book;
    }
}
  1. Run a GraphQL query:
query {
  book {
    title
    author {
      name
    }
    categories {
      edges {
        node {
          name
        }
      }
    }
  }
}

The result will show the single properties correctly, but the categories collection will show edges: null despite having items in it.

Possible Solution

There are currently one workaround:

  1. Disable pagination at the resource level:
#[ApiResource(
    paginationEnabled: false,
    // ...
)]

But this workaround shouldn't be necessary. The system should either:

  1. Automatically handle collections in DTOs for GraphQL operations just like it does for REST
  2. Properly document that collections in DTOs require special configuration for GraphQL
  3. Ensure that when pagination is enabled, the collection content is still properly normalized (not returned as null)

Additional Context

In the logs, we can verify that the collection has items, but they're not appearing in the GraphQL response unless pagination is disabled.

The issue seems to be in how API Platform handles the normalization process for collections in GraphQL vs REST operations. REST operations correctly handle collections in DTOs automatically, while GraphQL operations don't unless pagination is explicitly disabled.

When pagination is enabled (the default), the collection is wrapped in a cursor-based pagination structure, but the content is returned as null, despite the collection having items.

lcottingham avatar Mar 21 '25 10:03 lcottingham

Could you try using an API Platform Provider instead of a graphql resolver?

soyuka avatar Mar 21 '25 19:03 soyuka

Could you try using an API Platform Provider instead of a graphql resolver?

From what I’ve observed and logging it out, providers aren’t called for graphql item / collection query responses. Only resolvers

lcottingham avatar Mar 21 '25 23:03 lcottingham

providers and processors are definitely called https://github.com/api-platform/core/blob/main/src/GraphQl/State/Provider/ReadProvider.php

soyuka avatar Mar 24 '25 09:03 soyuka

in my codebase it seems Provider are only called in doctrine entities and not for DTOs.

Reinhard-Berger avatar Mar 24 '25 09:03 Reinhard-Berger

in my codebase it seems Provider are only called in doctrine entities and not for DTOs.

I'll be honest, I cannot get either mapped entity or unmapped DTO to call a decorated provider. The below provider class does not post to the logs at all, so I can only assume it isn't called.

I'm decorating the ItemProvider, but I could be wrong. Should we be decorating a different provider service for gql queries?

For pagination to work for these DTOs I have to return a PaginatorInterface, which I cannot do without decorating the provider.

readonly class TestProvider implements ProviderInterface
{
    public function __construct(
        #[Autowire(service: ItemProvider::class)] private ProviderInterface $provider,
        private LoggerInterface $logger
    )
    {
        error_log("Item provider called");
    }


    /**
     * @inheritDoc
     */
    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $this->logger->info("Item provider called");

        return $this->provider->provide($operation, $uriVariables, $context);
    }
}
new Query(provider: TestProvider::class),

Failing the above, I did try decorating the ReadProvider but then noticed it was final class. #[Autowire(service: ReadProvider::class)] private ProviderInterface $provider

This of course results in the following: [critical] Uncaught Exception: The service "App\StateProvider\TestProvider" has a dependency on a non-existent service "ApiPlatform\GraphQl\State\Provider\ReadProvider".

lcottingham avatar Mar 24 '25 13:03 lcottingham

Processors are definitely called though

lcottingham avatar Mar 24 '25 14:03 lcottingham

I need to investigate further I'm not using graphql that much, tagging this as a bug fix although this isn't my priority yet. If you have an idea on how to fix this I'll be glad to help.

soyuka avatar Mar 25 '25 17:03 soyuka