[3.1.18]Impossible de forcer la sous-ressource à générer un uri de type itemUriTemplate : '/users/{userId}/blogs/{blogId}
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;
}
}
Hi, @devDzign did you find something. I'm dealing with the same problem right now :cry:
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. 😌
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.