api-platform / core

The server component of API Platform: hypermedia and GraphQL APIs in minutes
https://api-platform.com
MIT License
2.4k stars 856 forks source link

[3.1.18]Impossible de forcer la sous-ressource à générer un uri de type itemUriTemplate : '/users/{userId}/blogs/{blogId} #6308

Closed devDzign closed 4 weeks ago

devDzign commented 4 months ago

I have created a "UserApi mapper" to map the Doctrine User entity and a "BlogApi mapper" to map the Doctrine Blog entity. I try to create a sub-resource of the type /api/users/{id}/blogs, with itemUriTemplate: '/users/{userId}/blogs/{blogId}'. However, I get an error :

Example of how to reproduce a bug :

<?php

namespace App\ApiResource;

#[ApiResource(
    shortName: 'users',
    operations: [
        new Get(
            uriTemplate: '/users/{id}'
        ),
    ],
    provider: UserProvider::class,
    stateOptions: new Options(entityClass: User::class),
)]
class UserApi
{

    #[ApiProperty(identifier: true)]
    public ?int $id =null;
    #[Assert\NotBlank()]
    public ?string $username=null;
    /** @var BlogApi[]  */
    public array $blogs = [];
}
#[ApiResource(
    shortName: 'blogs',
    operations: [
        new Get(
            uriTemplate: '/users/{userId}/blogs/{blogId}',
            uriVariables: [
                'userId' => new Link(
                    toProperty: 'owner',
                    fromClass: User::class
                ),
                'blogId' => new Link(
                    fromClass: Blog::class
                ),
            ],

        ),
        new GetCollection(
            uriTemplate: '/users/{userId}/blogs',
            uriVariables: [
                'userId' => new Link(
                    toProperty: 'owner',
                    fromClass: User::class,
                ),
            ],
            itemUriTemplate: '/users/{userId}/blogs/{blogId}',
        )
    ],
    paginationItemsPerPage: 5,
    provider: BlogProvider::class,
    processor: BlogProcessor::class,
    stateOptions: new Options(entityClass: Blog::class)

)]

class BlogApi
{
    #[ApiProperty(identifier: true)]
    public ?int $id = null;
    public ?UserApi $owner = null;

}
   {
    "@context": "/api/contexts/Error",
    "@type": "hydra:Error",
    "hydra:title": "An error occurred",
    "hydra:description": "Unable to generate an IRI for the item of type \"App\\ApiResource\\BlogApi\"",
}

UserProvider

use Symfonycasts\MicroMapper\MicroMapperInterface;

class UserProvider implements ProviderInterface
{

    public function __construct(
        #[Autowire(service: CollectionProvider::class)] private ProviderInterface $collectionProvider,
        #[Autowire(service: ItemProvider::class)] private ProviderInterface $itemProvider,
        private MicroMapperInterface $microMapper
    )
    {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $resourceClass = $operation->getClass();

        if ($operation instanceof CollectionOperationInterface) {
            $entities = $this->collectionProvider->provide($operation, $uriVariables, $context);

            assert($entities instanceof Paginator);

            $dtos = [];
            foreach ($entities as $entity) {
                $dtos[] = $this->mapEntityToDto($entity, $resourceClass);
            }

            return new TraversablePaginator(
                new \ArrayIterator($dtos),
                $entities->getCurrentPage(),
                $entities->getItemsPerPage(),
                $entities->getTotalItems()
            );
        }

        $entity = $this->itemProvider->provide($operation, $uriVariables, $context);

        if (!$entity) {
            return null;
        }

        return $this->mapEntityToDto($entity, $resourceClass);
    }

    private function mapEntityToDto(object $entity, string $resourceClass): object
    {
        return $this->microMapper->map($entity, $resourceClass);
    }
}

// UserProcessor

class UserProcessor implements ProcessorInterface
{

    public function __construct(
        #[Autowire(service: PersistProcessor::class)]
        private ProcessorInterface $persistProcessor,
        #[Autowire(service: RemoveProcessor::class)]
        private ProcessorInterface $removeProcessor,
        private MicroMapperInterface $microMapper
    )
    {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {

        $stateOptions = $operation->getStateOptions();
        assert($stateOptions instanceof Options);
        $entityClass = $stateOptions->getEntityClass();

        $entity = $this->mapDtoToEntity($data, $entityClass);

        if ($operation instanceof DeleteOperationInterface) {
            $this->removeProcessor->process($entity, $operation, $uriVariables, $context);

            return null;
        }

        $this->persistProcessor->process($entity, $operation, $uriVariables, $context);
        $data->id = $entity->getId();

        return $data;
    }

    private function mapDtoToEntity(object $dto, string $entityClass): object
    {
        return $this->microMapper->map($dto, $entityClass);
    }
}

// BlogProvider

use Symfonycasts\MicroMapper\MicroMapperInterface;

class BlogProvider implements ProviderInterface
{

    public function __construct(
        #[Autowire(service: CollectionProvider::class)]
        private ProviderInterface $collectionProvider,
        #[Autowire(service: ItemProvider::class)]
        private ProviderInterface $itemProvider,
        private MicroMapperInterface $microMapper
    )
    {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $resourceClass = $operation->getClass();

        if ($operation instanceof CollectionOperationInterface) {
            $entities = $this->collectionProvider->provide($operation, $uriVariables, $context);

            assert($entities instanceof Paginator);

            $dtos = [];
            foreach ($entities as $entity) {
                $dtos[] = $this->mapEntityToDto($entity, $resourceClass);
            }

            return new TraversablePaginator(
                new \ArrayIterator($dtos),
                $entities->getCurrentPage(),
                $entities->getItemsPerPage(),
                $entities->getTotalItems()
            );
        }

        $entity = $this->itemProvider->provide($operation, $uriVariables, $context);

        if (!$entity) {
            return null;
        }

        return $this->mapEntityToDto($entity, $resourceClass);
    }

    private function mapEntityToDto(object $entity, string $resourceClass): object
    {
        return $this->microMapper->map($entity, $resourceClass);
    }
}

//BlogProcessor

use Symfonycasts\MicroMapper\MicroMapperInterface;

class BlogProcessor implements ProcessorInterface
{

    public function __construct(
        #[Autowire(service: PersistProcessor::class)]
        private ProcessorInterface $persistProcessor,
        #[Autowire(service: RemoveProcessor::class)]
        private ProcessorInterface $removeProcessor,
        private MicroMapperInterface $microMapper
    )
    {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {

        $stateOptions = $operation->getStateOptions();
        assert($stateOptions instanceof Options);
        $entityClass = $stateOptions->getEntityClass();

        $entity = $this->mapDtoToEntity($data, $entityClass);

        if ($operation instanceof DeleteOperationInterface) {
            $this->removeProcessor->process($entity, $operation, $uriVariables, $context);

            return null;
        }

        $this->persistProcessor->process($entity, $operation, $uriVariables, $context);
        $data->id = $entity->getId();

        return $data;
    }

    private function mapDtoToEntity(object $dto, string $entityClass): object
    {
        return $this->microMapper->map($dto, $entityClass);
    }
}

To map entities and dto, I used the https://github.com/SymfonyCasts/micro-mapper 

//=================mapper entity Dto Api ========================

//User ==> UserApi
#[AsMapper(from: User::class, to: UserApi::class)]
class UserEntityToApiMapper implements MapperInterface
{
    public function __construct(
        private MicroMapperInterface $microMapper,
    )
    {
    }

    public function load(object $from, string $toClass, array $context): object
    {
        $entity = $from;
        assert($entity instanceof User);

        $dto = new UserApi();
        $dto->id = $entity->getId();

        return $dto;
    }

    public function populate(object $from, object $to, array $context): object
    {
        $entity = $from;
        $dto = $to;
        assert($entity instanceof User);
        assert($dto instanceof UserApi);

        $dto->email = $entity->getEmail();
        $dto->firstname = $entity->getFirstname();
        $dto->lastname = $entity->getLastname();
        $dto->blogs = array_map(function(Blog $blog) {
            return $this->microMapper->map($blog, BlogApi::class, [
                MicroMapperInterface::MAX_DEPTH => 0,
            ]);
        }, $entity->getBlogs()->getValues());

        return $dto;
    }
}

//Blog  ==>BlogApi

#[AsMapper(from: Blog::class, to: BlogApi::class)]
class BlogEntityToApiMapper implements MapperInterface
{
    public function __construct(
        private MicroMapperInterface $microMapper,
    )
    {
    }

    public function load(object $from, string $toClass, array $context): object
    {
        $entity = $from;
        assert($entity instanceof Blog);

        $dto = new BlogApi();
        $dto->id = $entity->getId();

        return $dto;
    }

    public function populate(object $from, object $to, array $context): object
    {
        $entity = $from;
        $dto = $to;
        assert($entity instanceof Blog);
        assert($dto instanceof BlogApi);

        $dto->title = $entity->getTitle();
        $dto->description = $entity->getDescription();
        $dto->owner = $this->microMapper->map($entity->getOwner(), UserApi::class, [
            MicroMapperInterface::MAX_DEPTH => 0,
        ]);

        $dto->blogs = array_map(function(Comment $comment) {
            return $this->microMapper->map($comment, CommentApi::class, [
                MicroMapperInterface::MAX_DEPTH => 0,
            ]);
        }, $entity->getComments()->getValues());

        return $dto;
    }
}

//Comment ==> CommentApi

#[AsMapper(from: Comment::class, to: CommentApi::class)]
class CommentEntityToApiMapper implements MapperInterface
{
    public function __construct(
        private MicroMapperInterface $microMapper,
    )
    {
    }

    public function load(object $from, string $toClass, array $context): object
    {
        $entity = $from;
        assert($entity instanceof Comment);

        $dto = new CommentApi();
        $dto->id = $entity->getId();

        return $dto;
    }

    public function populate(object $from, object $to, array $context): object
    {
        $entity = $from;
        $dto = $to;
        assert($entity instanceof Comment);
        assert($dto instanceof CommentApi);

        $dto->content = $entity->getContent();
        $dto->owner = $this->microMapper->map($entity->getOwner(), UserApi::class, [
            MicroMapperInterface::MAX_DEPTH => 0,
        ]);

        $dto->blog = $this->microMapper->map($entity->getBlog(), BlogApi::class, [
            MicroMapperInterface::MAX_DEPTH => 0,
        ]);

        return $dto;
    }
}

=========== Dto => Entity =============

UserApi => User

#[AsMapper(from: UserApi::class, to: User::class)]
class UserApiToEntityMapper implements MapperInterface
{
    public function __construct(
        private UserRepository $userRepository,
        private UserPasswordHasherInterface $userPasswordHasher,
        private MicroMapperInterface $microMapper,
        private PropertyAccessorInterface $propertyAccessor,
    )
    {
    }

    public function load(object $from, string $toClass, array $context): object
    {
        $dto = $from;
        assert($dto instanceof UserApi);

        $userEntity = $dto->id ? $this->userRepository->find($dto->id) : new User();
        if (!$userEntity) {
            throw new \Exception('User not found');
        }

        return $userEntity;
    }

    public function populate(object $from, object $to, array $context): object
    {
        $dto = $from;
        assert($dto instanceof UserApi);
        $entity = $to;
        assert($entity instanceof User);

        $entity->setEmail($dto->email);
        $entity->setFirstname($dto->firstname);
        $entity->setLastname($dto->lastname);
        if ($dto->password) {
            $entity->setPassword($this->userPasswordHasher->hashPassword($entity, $dto->password));
        }

        $blogs = [];
        foreach ($dto->$blogs as $blogApi) {
            $blogs[] = $this->microMapper->map($blogApi, Blog::class, [
                MicroMapperInterface::MAX_DEPTH => 0,
            ]);
        }

        $this->propertyAccessor->setValue($entity, 'blogs', $blogs);

        return $entity;
    }
}

// BlogApi => Blog

#[AsMapper(from: BlogApi::class, to: Blog::class)]
class BlogApiToEntityMapper implements MapperInterface
{
    public function __construct(
        private BlogRepository $blogRepository,
        private UserRepository $userRepository,
        private MicroMapperInterface $microMapper,
        private PropertyAccessorInterface $propertyAccessor,
        private Security $security)
    {
    }

    public function load(object $from, string $toClass, array $context): object
    {
        $dto = $from;
        assert($dto instanceof BlogApi);

        $userEntity = $dto->id ? $this->blogRepository->find($dto->id) : new Blog();
        if (!$userEntity) {
            throw new \Exception('User not found');
        }

        return $userEntity;
    }

    public function populate(object $from, object $to, array $context): object
    {
        $dto = $from;
        $entity = $to;
        assert($dto instanceof BlogApi);
        assert($entity instanceof Blog);

        if ($dto->owner) {
            $entity->setOwner($this->microMapper->map($dto->owner, User::class, [
                MicroMapperInterface::MAX_DEPTH => 0,
            ]));
        } 

        $comments = [];
        foreach ($dto->comments as $commentApi) {
            $comments[] = $this->microMapper->map($commentApi, Comment::class, [
                MicroMapperInterface::MAX_DEPTH => 0,
            ]);
        }
        $this->propertyAccessor->setValue($entity, 'comments', $comments);

        $entity->setDescription($dto->description);
        $entity->setTitle($dto->title);

        return $entity;
    }
}

// CommentApi => Comment

#[AsMapper(from: CommentApi::class, to: Comment::class)]
class CommentApiToEntityMapper implements MapperInterface
{
    public function __construct(
        private BlogRepository $blogRepository,
        private UserRepository $userRepository,
        private CommentRepository $commentRepository,
        private MicroMapperInterface $microMapper,
        private PropertyAccessorInterface $propertyAccessor,
        private Security $security)
    {
    }

    public function load(object $from, string $toClass, array $context): object
    {
        $dto = $from;
        assert($dto instanceof CommentApi);

        $userEntity = $dto->id ? $this->commentRepository->find($dto->id) : new Comment();
        if (!$userEntity) {
            throw new \RuntimeException('Comment not found');
        }

        return $userEntity;
    }

    public function populate(object $from, object $to, array $context): object
    {
        $dto = $from;
        $entity = $to;
        assert($dto instanceof CommentApi);
        assert($entity instanceof Comment);

        if ($dto->owner) {
            $entity->setOwner($this->microMapper->map($dto->owner, User::class, [
                MicroMapperInterface::MAX_DEPTH => 0,
            ]));
        }

        if ($dto->blog) {
            $entity->setBlog($this->blogRepository->findAll()[0]);
        }
        $entity->setCreatedAt(new \DateTimeImmutable());

        $entity->setContent($dto->content);

        return $entity;
    }
}
emilien-gts commented 3 months ago

Hi, @devDzign did you find something. I'm dealing with the same problem right now :cry:

devDzign commented 3 months ago

Hi, @devDzign did you find something. I'm dealing with the same problem right now 😢

I'm still waiting for a response from the API Platform core team. 😌

stale[bot] commented 1 month ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.