diff --git a/docs/guides/subresource.php b/docs/guides/subresource.php index 7ccee093e0d..0bf80024b6c 100644 --- a/docs/guides/subresource.php +++ b/docs/guides/subresource.php @@ -90,8 +90,7 @@ final class Migration extends AbstractMigration public function up(Schema $schema): void { $this->addSql('CREATE TABLE company (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); - $this->addSql('CREATE TABLE employee (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, company_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, CONSTRAINT FK_COMPANY FOREIGN KEY (company_id) REFERENCES company (id) NOT DEFERRABLE INITIALLY IMMEDIATE); -'); + $this->addSql('CREATE TABLE employee (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, company_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, CONSTRAINT FK_COMPANY FOREIGN KEY (company_id) REFERENCES company (id) NOT DEFERRABLE INITIALLY IMMEDIATE);'); $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); } } diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index 119a64a8b61..50718f81963 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -76,7 +76,7 @@ Feature: Search filter on collections }, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,colors,colors[],secondColors,secondColors[],thirdColors,thirdColors[],uuid,uuid[],name}", + "hydra:template": "/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,colors,colors[],secondColors,secondColors[],thirdColors,thirdColors[],uuid,uuid[],name,brand,brand[]}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -186,6 +186,18 @@ Feature: Search filter on collections "variable": "name", "property": "name", "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "brand", + "property": "brand", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "brand[]", + "property": "brand", + "required": false } ] } diff --git a/features/mercure/publish.feature b/features/mercure/publish.feature new file mode 100644 index 00000000000..ee7e3b7793f --- /dev/null +++ b/features/mercure/publish.feature @@ -0,0 +1,33 @@ +Feature: Mercure publish support + In order to publish an Update to the Mercure hub + As a developer + I need to specify which topics I want to send the Update on + + @createSchema + # see https://github.com/api-platform/core/issues/5074 + Scenario: Checks that Mercure Updates are dispatched properly + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + When I send a "POST" request to "/issue5074/mercure_with_topics" with body: + """ + { + "name": "Hello World!", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + Then 1 Mercure update should have been sent + And the Mercure update should have topics: + | http://example.com/issue5074/mercure_with_topics/1 | + And the Mercure update should have data: + """ + { + "@context": "/contexts/MercureWithTopics", + "@id": "/issue5074/mercure_with_topics/1", + "@type": "MercureWithTopics", + "id": 1, + "name": "Hello World!" + } + """ diff --git a/src/Doctrine/Common/Filter/SearchFilterInterface.php b/src/Doctrine/Common/Filter/SearchFilterInterface.php index a96042cabc7..198129dc4f5 100644 --- a/src/Doctrine/Common/Filter/SearchFilterInterface.php +++ b/src/Doctrine/Common/Filter/SearchFilterInterface.php @@ -26,23 +26,48 @@ interface SearchFilterInterface */ public const STRATEGY_EXACT = 'exact'; + /** + * @var string Exact matching case-insensitive + */ + public const STRATEGY_IEXACT = 'iexact'; + /** * @var string The value must be contained in the field */ public const STRATEGY_PARTIAL = 'partial'; + /** + * @var string The value must be contained in the field case-insensitive + */ + public const STRATEGY_IPARTIAL = 'partial'; + /** * @var string Finds fields that are starting with the value */ public const STRATEGY_START = 'start'; + /** + * @var string Finds fields that are starting with the value case-insensitive + */ + public const STRATEGY_ISTART = 'start'; + /** * @var string Finds fields that are ending with the value */ public const STRATEGY_END = 'end'; + /** + * @var string Finds fields that are ending with the value case-insensitive + */ + public const STRATEGY_IEND = 'end'; + /** * @var string Finds fields that are starting with the word */ public const STRATEGY_WORD_START = 'word_start'; + + /** + * @var string Finds fields that are starting with the word case-insensitive + */ + public const STRATEGY_IWORD_START = 'word_start'; } diff --git a/src/Doctrine/Common/Filter/SearchFilterTrait.php b/src/Doctrine/Common/Filter/SearchFilterTrait.php index 2d30000ed72..35860a846ae 100644 --- a/src/Doctrine/Common/Filter/SearchFilterTrait.php +++ b/src/Doctrine/Common/Filter/SearchFilterTrait.php @@ -68,7 +68,7 @@ public function getDescription(string $resourceClass): array $strategy = $this->getProperties()[$property] ?? self::STRATEGY_EXACT; $filterParameterNames = [$propertyName]; - if (self::STRATEGY_EXACT === $strategy) { + if (\in_array($strategy, [self::STRATEGY_EXACT, self::STRATEGY_IEXACT], true)) { $filterParameterNames[] = $propertyName.'[]'; } diff --git a/src/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Doctrine/EventListener/PublishMercureUpdatesListener.php index 36724f0141e..ba1e1377473 100644 --- a/src/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -192,35 +192,15 @@ private function storeObjectToPublish(object $object, string $property): void $options['enable_async_update'] ??= true; - if ($options['topics'] ?? false) { - $topics = []; - foreach ((array) $options['topics'] as $topic) { - if (!\is_string($topic)) { - $topics[] = $topic; - continue; - } - - if (!str_starts_with($topic, '@=')) { - $topics[] = $topic; - continue; - } - - if (null === $this->expressionLanguage) { - throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".'); - } - - $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]); - } - - $options['topics'] = $topics; - } - if ('deletedObjects' === $property) { $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; if (null === $types) { $types = [$operation->getShortName()]; } + // We need to evaluate it here, because in publishUpdate() the resource would be already deleted + $this->evaluateTopics($options, $object); + $this->deletedObjects[(object) [ 'id' => $this->iriConverter->getIriFromResource($object), 'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL), @@ -246,6 +226,9 @@ private function publishUpdate(object $object, array $options, string $type): vo $resourceClass = $this->getObjectClass($object); $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? []; + // We need to evaluate it here, because in storeObjectToPublish() the resource would not have been persisted yet + $this->evaluateTopics($options, $object); + $iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL); $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context); } @@ -262,6 +245,34 @@ private function publishUpdate(object $object, array $options, string $type): vo } } + private function evaluateTopics(array &$options, object $object): void + { + if (!($options['topics'] ?? false)) { + return; + } + + $topics = []; + foreach ((array) $options['topics'] as $topic) { + if (!\is_string($topic)) { + $topics[] = $topic; + continue; + } + + if (!str_starts_with($topic, '@=')) { + $topics[] = $topic; + continue; + } + + if (null === $this->expressionLanguage) { + throw new \LogicException('The "@=" expression syntax cannot be used without the Expression Language component. Try running "composer require symfony/expression-language".'); + } + + $topics[] = $this->expressionLanguage->evaluate(substr($topic, 2), ['object' => $object]); + } + + $options['topics'] = $topics; + } + /** * @return Update[] */ diff --git a/tests/Behat/MercureContext.php b/tests/Behat/MercureContext.php index c88a62fe4c3..ebcc90a2796 100644 --- a/tests/Behat/MercureContext.php +++ b/tests/Behat/MercureContext.php @@ -15,7 +15,10 @@ use Behat\Behat\Context\Context; use Behat\Gherkin\Node\PyStringNode; +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; use Psr\Container\ContainerInterface; +use Symfony\Component\Mercure\Update; /** * Context for Mercure. @@ -28,6 +31,80 @@ public function __construct(private readonly ContainerInterface $driverContainer { } + /** + * @Then :number Mercure updates should have been sent + * @Then :number Mercure update should have been sent + */ + public function mercureUpdatesShouldHaveBeenSent(int $number): void + { + $updateHandler = $this->driverContainer->get('mercure.hub.default.message_handler'); + $total = \count($updateHandler->getUpdates()); + + if (0 === $total) { + throw new \RuntimeException('No Mercure update has been sent.'); + } + + Assert::assertEquals($number, $total, sprintf('Expected %d Mercure updates to be sent, got %d.', $number, $total)); + } + + /** + * @Then the first Mercure update should have topics: + * @Then the Mercure update should have topics: + */ + public function firstMercureUpdateShouldHaveTopics(TableNode $table): void + { + $this->mercureUpdateShouldHaveTopics(1, $table); + } + + /** + * @Then the first Mercure update should have data: + * @Then the Mercure update should have data: + */ + public function firstMercureUpdateShouldHaveData(PyStringNode $data): void + { + $this->mercureUpdateShouldHaveData(1, $data); + } + + /** + * @Then the Mercure update number :index should have topics: + */ + public function mercureUpdateShouldHaveTopics(int $index, TableNode $table): void + { + $updateHandler = $this->driverContainer->get('mercure.hub.default.message_handler'); + $updates = $updateHandler->getUpdates(); + + if (0 === \count($updates)) { + throw new \RuntimeException('No Mercure update has been sent.'); + } + + if (!isset($updates[$index - 1])) { + throw new \RuntimeException(sprintf('Mercure update #%d does not exist.', $index)); + } + /** @var Update $update */ + $update = $updates[$index - 1]; + Assert::assertEquals(array_keys($table->getRowsHash()), array_values($update->getTopics())); + } + + /** + * @Then the Mercure update number :index should have data: + */ + public function mercureUpdateShouldHaveData(int $index, PyStringNode $data): void + { + $updateHandler = $this->driverContainer->get('mercure.hub.default.message_handler'); + $updates = $updateHandler->getUpdates(); + + if (0 === \count($updates)) { + throw new \RuntimeException('No Mercure update has been sent.'); + } + + if (!isset($updates[$index - 1])) { + throw new \RuntimeException(sprintf('Mercure update #%d does not exist.', $index)); + } + /** @var Update $update */ + $update = $updates[$index - 1]; + Assert::assertJsonStringEqualsJsonString($data->getRaw(), $update->getData()); + } + /** * @Then the following Mercure update with topics :topics should have been sent: */ diff --git a/tests/Fixtures/TestBundle/Document/DummyCar.php b/tests/Fixtures/TestBundle/Document/DummyCar.php index 16d482326c2..db3fe080125 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCar.php +++ b/tests/Fixtures/TestBundle/Document/DummyCar.php @@ -65,6 +65,7 @@ class DummyCar private ?bool $canSell = null; #[ODM\Field(type: 'date')] private ?\DateTime $availableAt = null; + #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IEXACT)] #[Serializer\Groups(['colors'])] #[Serializer\SerializedName('carBrand')] #[ODM\Field] diff --git a/tests/Fixtures/TestBundle/Document/Issue5074/MercureWithTopics.php b/tests/Fixtures/TestBundle/Document/Issue5074/MercureWithTopics.php new file mode 100644 index 00000000000..6688909b286 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Issue5074/MercureWithTopics.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Issue5074; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/issue5074/mercure_with_topics/{id}{._format}'), + new Post(uriTemplate: '/issue5074/mercure_with_topics{._format}'), + ], + mercure: ['topics' => '@=iri(object)'], + extraProperties: ['standard_put' => false] +)] +#[ODM\Document] +class MercureWithTopics +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + public $id; + #[ODM\Field(type: 'string')] + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index 080abef640e..3991a1da6a8 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -73,6 +73,7 @@ class DummyCar private bool $canSell; #[ORM\Column(type: 'datetime')] private \DateTime $availableAt; + #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IEXACT)] #[Serializer\Groups(['colors'])] #[Serializer\SerializedName('carBrand')] #[ORM\Column] diff --git a/tests/Fixtures/TestBundle/Entity/Issue5074/MercureWithTopics.php b/tests/Fixtures/TestBundle/Entity/Issue5074/MercureWithTopics.php new file mode 100644 index 00000000000..acc22f05e95 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5074/MercureWithTopics.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5074; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + operations: [ + new Get(uriTemplate: '/issue5074/mercure_with_topics/{id}{._format}'), + new Post(uriTemplate: '/issue5074/mercure_with_topics{._format}'), + ], + mercure: ['topics' => '@=iri(object)'], + extraProperties: ['standard_put' => false] +)] +#[ORM\Entity] +class MercureWithTopics +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public $id; + #[ORM\Column] + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5625/Currency.php b/tests/Fixtures/TestBundle/Entity/Issue5625/Currency.php new file mode 100644 index 00000000000..7086fdccff8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5625/Currency.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5625; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\OpenApi\Model\Operation; + +/** + * Currency. + */ +#[ApiResource(operations: [ + new Get(uriTemplate: '/get_security_1', openapi: new Operation(security: [['JWT' => ['CURRENCY_READ']]])), +])] +class Currency +{ + public $id; + public $name; +} diff --git a/tests/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Symfony/Bundle/Command/OpenApiCommandTest.php index 7b3b1b648d0..f012a8bcc52 100644 --- a/tests/Symfony/Bundle/Command/OpenApiCommandTest.php +++ b/tests/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -41,7 +41,6 @@ protected function setUp(): void $application = new Application(static::$kernel); $application->setCatchExceptions(false); $application->setAutoExit(false); - $this->tester = new ApplicationTester($application); $this->handleDeprecations(); @@ -59,6 +58,7 @@ public function testExecuteWithYaml(): void $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); $result = $this->tester->getDisplay(); + $this->assertYaml($result); $operationId = 'api_dummy_cars_get_collection'; @@ -96,6 +96,14 @@ public function testExecuteWithYaml(): void YAML; $this->assertStringContainsString(str_replace(\PHP_EOL, "\n", $expected), $result); + + $expected = <<assertStringContainsString(str_replace(\PHP_EOL, "\n", $expected), $result); } public function testWriteToFile(): void