Use ResourceClassResolver in IriConverter to fix discriminator subcla… (#19046)
Some checks failed
Continuous Integration (Minimal) / Static checks (push) Has been cancelled
Continuous Integration (Minimal) / Javascript Tests (MySQL) (push) Has been cancelled
Continuous Integration (Minimal) / Tests (PostgreSQL) (push) Has been cancelled
Continuous Integration (Minimal) / Frontend (push) Has been cancelled
Continuous Integration (Minimal) / Packages (push) Has been cancelled
Continuous Integration (Minimal) / Tests (MariaDB) (push) Has been cancelled
Continuous Integration (Minimal) / Tests (MySQL) (push) Has been cancelled

…ss IRI generation

| Q               | A
|-----------------|-----
| Branch?         | 2.2
| Bug fix?        | yes
| New feature?    | no
| BC breaks?      | no
| Deprecations?   | no
| Related tickets | fixes https://github.com/Sylius/Sylius/issues/18781
| License         | MIT

<!--
 - Bug fixes must be submitted against the 2.2 branch
 - Features and deprecations must be submitted against the 2.3 branch
 - Make sure that the correct base branch is set

To be sure you are not breaking any Backward Compatibilities, check the
documentation:

https://docs.sylius.com/en/latest/book/organization/backward-compatibility-promise.html
-->
This commit is contained in:
Kamil Grygierzec 2026-06-19 09:17:01 +02:00 committed by GitHub
commit 92ef8a2200
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 329 additions and 1 deletions

View file

@ -1,5 +1,19 @@
# UPGRADE FROM `2.2.6` TO `2.2.7`
## Constructor Signature Changes
1. The constructor of `Sylius\Bundle\ApiBundle\ApiPlatform\Routing\IriConverter` has been extended with an optional `ApiPlatform\Metadata\ResourceClassResolverInterface` argument.
```php
public function __construct(
IriConverterInterface $decoratedIriConverter,
PathPrefixProviderInterface $pathPrefixProvider,
OperationResolverInterface $operationResolver,
RouterInterface $router,
+ ?ResourceClassResolverInterface $resourceClassResolver = null,
)
```
## Bahavior changes
1. The `LiveComponentTagPass` and `TwigComponentTagPass` in `SyliusUiBundle` were registered with a priority of `500`,

View file

@ -29,6 +29,7 @@
"ApiPlatform\\Metadata\\QueryParameter",
"ApiPlatform\\Metadata\\Resource\\Factory\\ResourceMetadataCollectionFactoryInterface",
"ApiPlatform\\Metadata\\Resource\\ResourceMetadataCollection",
"ApiPlatform\\Metadata\\ResourceClassResolverInterface",
"ApiPlatform\\Metadata\\UriVariablesConverterInterface",
"ApiPlatform\\Metadata\\UrlGeneratorInterface",
"ApiPlatform\\Metadata\\Util\\ClassInfoTrait",

View file

@ -15,6 +15,7 @@ namespace Sylius\Bundle\ApiBundle\ApiPlatform\Routing;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use Sylius\Bundle\ApiBundle\Provider\PathPrefixProviderInterface;
@ -30,6 +31,7 @@ final readonly class IriConverter implements IriConverterInterface
private PathPrefixProviderInterface $pathPrefixProvider,
private OperationResolverInterface $operationResolver,
private RouterInterface $router,
private ?ResourceClassResolverInterface $resourceClassResolver = null,
) {
}
@ -44,7 +46,16 @@ final readonly class IriConverter implements IriConverterInterface
?Operation $operation = null,
array $context = [],
): ?string {
$resourceClass = $context['force_resource_class'] ?? (\is_string($resource) ? $resource : $this->getObjectClass($resource));
if (isset($context['force_resource_class'])) {
$resourceClass = $context['force_resource_class'];
} elseif (\is_string($resource)) {
$resourceClass = $resource;
} else {
$objectClass = $this->getObjectClass($resource);
$resourceClass = $this->resourceClassResolver !== null && $this->resourceClassResolver->isResourceClass($objectClass)
? $this->resourceClassResolver->getResourceClass($resource)
: $objectClass;
}
$pathPrefix = $this->pathPrefixProvider->getPathPrefix($context['request_uri'] ?? $this->router->getContext()->getPathInfo());
$operation = $this->operationResolver->resolve($resourceClass, $pathPrefix, $operation);

View file

@ -35,6 +35,7 @@
<argument type="service" id="sylius_api.provider.path_prefix" />
<argument type="service" id="sylius_api.operation_resolver.path_prefix_based" />
<argument type="service" id="api_platform.router" />
<argument type="service" id="api_platform.resource_class_resolver" />
</service>
<service

View file

@ -26,6 +26,7 @@
"php": "^8.2",
"doctrine/dbal": "^3.9",
"api-platform/doctrine-orm": "^4.2.1",
"api-platform/metadata": "^4.2.1",
"api-platform/symfony": "^4.2.1",
"lexik/jwt-authentication-bundle": "^3.1",
"sylius/core-bundle": "^2.0",

View file

@ -15,6 +15,7 @@ namespace Tests\Sylius\Bundle\ApiBundle\ApiPlatform\Routing;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@ -35,6 +36,8 @@ final class IriConverterTest extends TestCase
private MockObject&RouterInterface $router;
private MockObject&ResourceClassResolverInterface $resourceClassResolver;
private IriConverter $iriConverter;
private CountryInterface&MockObject $country;
@ -46,11 +49,13 @@ final class IriConverterTest extends TestCase
$this->pathPrefixProvider = $this->createMock(PathPrefixProviderInterface::class);
$this->operationResolver = $this->createMock(OperationResolverInterface::class);
$this->router = $this->createMock(RouterInterface::class);
$this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
$this->iriConverter = new IriConverter(
$this->decoratedIriConverter,
$this->pathPrefixProvider,
$this->operationResolver,
$this->router,
$this->resourceClassResolver,
);
$this->country = $this->createMock(CountryInterface::class);
}
@ -104,4 +109,92 @@ final class IriConverterTest extends TestCase
],
));
}
public function testResolvesDiscriminatorSubclassToParentResourceClassBeforeResolvingOperation(): void
{
/** @var Operation&MockObject $shopOperation */
$shopOperation = $this->createMock(Operation::class);
$concreteSubclassInstance = new Country();
$this->resourceClassResolver->expects(self::once())
->method('isResourceClass')
->with(Country::class)
->willReturn(true);
$this->resourceClassResolver->expects(self::once())
->method('getResourceClass')
->with($concreteSubclassInstance)
->willReturn(CountryInterface::class);
$this->pathPrefixProvider->expects(self::once())
->method('getPathPrefix')
->with('api/v2/shop/countries')
->willReturn('shop');
$this->operationResolver->expects(self::once())
->method('resolve')
->with(CountryInterface::class, 'shop', null)
->willReturn($shopOperation);
$this->decoratedIriConverter->expects(self::once())
->method('getIriFromResource')
->with(self::identicalTo($concreteSubclassInstance), UrlGeneratorInterface::ABS_PATH, $shopOperation, [
'request_uri' => 'api/v2/shop/countries',
])
->willReturn('api/v2/shop/countries/CODE');
self::assertSame(
'api/v2/shop/countries/CODE',
$this->iriConverter->getIriFromResource(
$concreteSubclassInstance,
UrlGeneratorInterface::ABS_PATH,
null,
['request_uri' => 'api/v2/shop/countries'],
),
);
}
public function testUsesConcreteClassDirectlyWhenItIsNotAKnownResourceClass(): void
{
/** @var Operation&MockObject $operation */
$operation = $this->createMock(Operation::class);
$concreteInstance = new Country();
$this->resourceClassResolver->expects(self::once())
->method('isResourceClass')
->with(Country::class)
->willReturn(false);
$this->resourceClassResolver->expects(self::never())
->method('getResourceClass');
$this->pathPrefixProvider->expects(self::once())
->method('getPathPrefix')
->with('api/v2/shop/countries')
->willReturn('shop');
$this->operationResolver->expects(self::once())
->method('resolve')
->with(Country::class, 'shop', null)
->willReturn($operation);
$this->decoratedIriConverter->expects(self::once())
->method('getIriFromResource')
->with(self::identicalTo($concreteInstance), UrlGeneratorInterface::ABS_PATH, $operation, [
'request_uri' => 'api/v2/shop/countries',
])
->willReturn('api/v2/shop/countries/CODE');
self::assertSame(
'api/v2/shop/countries/CODE',
$this->iriConverter->getIriFromResource(
$concreteInstance,
UrlGeneratorInterface::ABS_PATH,
null,
['request_uri' => 'api/v2/shop/countries'],
),
);
}
}

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
This file is part of the Sylius package.
(c) Sylius Sp. z o.o.
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<resources
xmlns="https://api-platform.com/schema/metadata/resources-3.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0 https://api-platform.com/schema/metadata/resources-3.0.xsd"
>
<resource class="Sylius\Bundle\ApiBundle\Application\Resource\SampleProduct">
<operations>
<operation
class="ApiPlatform\Metadata\GetCollection"
uriTemplate="/admin/sample-products"
name="sylius_api_admin_sample_product_get_collection"
provider="Sylius\Bundle\ApiBundle\Application\Resource\SampleProductProvider"
/>
<operation
class="ApiPlatform\Metadata\Get"
uriTemplate="/admin/sample-products/{id}"
name="sylius_api_admin_sample_product_get"
provider="Sylius\Bundle\ApiBundle\Application\Resource\SampleProductProvider"
/>
<operation
class="ApiPlatform\Metadata\GetCollection"
uriTemplate="/shop/sample-products"
name="sylius_api_shop_sample_product_get_collection"
provider="Sylius\Bundle\ApiBundle\Application\Resource\SampleProductProvider"
/>
<operation
class="ApiPlatform\Metadata\Get"
uriTemplate="/shop/sample-products/{id}"
name="sylius_api_shop_sample_product_get"
provider="Sylius\Bundle\ApiBundle\Application\Resource\SampleProductProvider"
/>
</operations>
</resource>
</resources>

View file

@ -1,4 +1,7 @@
services:
Sylius\Bundle\ApiBundle\Application\Resource\SampleProductProvider:
tags: ['api_platform.state_provider']
Sylius\Bundle\ApiBundle\Application\CommandHandler\FooHandler:
tags:
- { name: messenger.message_handler, bus: sylius.command_bus }

View file

@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Application\Resource;
class SampleProduct
{
public function __construct(
private int $id,
private string $name,
) {
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
}

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Application\Resource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
/** @implements ProviderInterface<SampleProduct> */
final readonly class SampleProductProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object
{
if ($operation instanceof CollectionOperationInterface) {
return [new SpecialSampleProduct(1, 'Special Product')];
}
return new SpecialSampleProduct((int) ($uriVariables['id'] ?? 1), 'Special Product');
}
}

View file

@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Application\Resource;
/**
* Simulates an entity subclass (e.g. a Doctrine discriminator map subclass)
* that is not an API resource itself, but extends a registered API resource.
*/
class SpecialSampleProduct extends SampleProduct
{
}

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Application\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use PHPUnit\Framework\Attributes\Test;
final class SampleProductTest extends ApiTestCase
{
use SetUpTestsTrait;
public function setUp(): void
{
$this->setFixturesFiles([]);
$this->setUpTest();
}
#[Test]
public function it_generates_correct_iris_for_subclass_instances_on_admin_endpoint(): void
{
static::createClient()->request(
'GET',
'api/v2/admin/sample-products',
['auth_bearer' => $this->JWTAdminUserToken],
);
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'@context' => '/api/v2/contexts/SampleProduct',
'@id' => '/api/v2/admin/sample-products',
'@type' => 'hydra:Collection',
'hydra:member' => [[
'@id' => '/api/v2/admin/sample-products/1',
'@type' => 'SampleProduct',
'name' => 'Special Product',
]],
]);
}
#[Test]
public function it_generates_correct_iris_for_subclass_instances_on_shop_endpoint(): void
{
static::createClient()->request('GET', 'api/v2/shop/sample-products');
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'@context' => '/api/v2/contexts/SampleProduct',
'@id' => '/api/v2/shop/sample-products',
'@type' => 'hydra:Collection',
'hydra:member' => [[
'@id' => '/api/v2/shop/sample-products/1',
'@type' => 'SampleProduct',
'name' => 'Special Product',
]],
]);
}
}