Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
devDzign opened this issue Apr 12, 2024 · 0 comments

Comments

@devDzign
Copy link

devDzign commented Apr 12, 2024

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;
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant