From fc8fa00a19320b65547a60537261959c11f8e6a8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 25 Oct 2024 11:44:09 +0200 Subject: [PATCH 1/3] fix(hydra): iri template when using query parameter (#6742) --- .../CollectionFiltersNormalizer.php | 4 +- ...meterResourceMetadataCollectionFactory.php | 29 ---- src/OpenApi/Factory/OpenApiFactory.php | 152 ++++++++++-------- .../Tests/Factory/OpenApiFactoryTest.php | 3 +- .../ApiResource/FilterWithStateOptions.php | 36 +++++ .../Entity/FilterWithStateOptionsEntity.php | 32 ++++ tests/Functional/Parameters/DoctrineTest.php | 71 ++++++-- 7 files changed, 214 insertions(+), 113 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index f262a559d78..d44cfb29b1d 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -171,7 +171,7 @@ private function getSearch(string $resourceClass, array $parts, array $filters, continue; } - if (!($property = $parameter->getProperty()) && ($filterId = $parameter->getFilter()) && ($filter = $this->getFilter($filterId))) { + if (($filterId = $parameter->getFilter()) && ($filter = $this->getFilter($filterId))) { foreach ($filter->getDescription($resourceClass) as $variable => $description) { // This is a practice induced by PHP and is not necessary when implementing URI template if (str_ends_with((string) $variable, '[]')) { @@ -192,7 +192,7 @@ private function getSearch(string $resourceClass, array $parts, array $filters, continue; } - if (!$property) { + if (!($property = $parameter->getProperty())) { continue; } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index c422696a95b..12a89a50fb9 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -16,7 +16,6 @@ use ApiPlatform\Doctrine\Odm\State\Options as DoctrineOdmOptions; use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOrmOptions; use ApiPlatform\Metadata\FilterInterface; -use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; @@ -126,38 +125,10 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $parameter = $parameter->withSchema($schema); } - if (null === $parameter->getProperty() && ($property = $description[$key]['property'] ?? null)) { - $parameter = $parameter->withProperty($property); - } - if (null === $parameter->getRequired() && ($required = $description[$key]['required'] ?? null)) { $parameter = $parameter->withRequired($required); } - if (null === $parameter->getOpenApi() && $openApi = $description[$key]['openapi'] ?? null) { - if ($openApi instanceof OpenApiParameter) { - $parameter = $parameter->withOpenApi($openApi); - } elseif (\is_array($openApi)) { - $schema = $schema ?? $openApi['schema'] ?? []; - $parameter = $parameter->withOpenApi(new OpenApiParameter( - $key, - $parameter instanceof HeaderParameterInterface ? 'header' : 'query', - $description[$key]['description'] ?? '', - $description[$key]['required'] ?? $openApi['required'] ?? false, - $openApi['deprecated'] ?? false, - $openApi['allowEmptyValue'] ?? true, - $schema, - $openApi['style'] ?? null, - $openApi['explode'] ?? ('array' === ($schema['type'] ?? null)), - $openApi['allowReserved'] ?? false, - $openApi['example'] ?? null, - isset( - $openApi['examples'] - ) ? new \ArrayObject($openApi['examples']) : null - )); - } - } - $schema = $parameter->getSchema() ?? (($openApi = $parameter->getOpenApi()) ? $openApi->getSchema() : null); // Only add validation if the Symfony Validator is installed diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index b05d6a5e67b..7bdf676f6a2 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -331,12 +331,26 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } + $entityClass = $this->getFilterClass($operation); $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getParameters() ?? [] as $key => $p) { if (false === $p->getOpenApi()) { continue; } + if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator->has($f)) { + $filter = $this->filterLocator->get($f); + foreach ($filter->getDescription($entityClass) as $name => $description) { + if ($prop = $p->getProperty()) { + $name = str_replace($prop, $key, $name); + } + + $openapiParameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $f); + } + + continue; + } + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); @@ -654,92 +668,102 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array { $parameters = []; - $resourceFilters = $operation->getFilters(); + $entityClass = $this->getFilterClass($operation); + foreach ($resourceFilters ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { continue; } $filter = $this->filterLocator->get($filterId); - $entityClass = $operation->getClass(); - if ($options = $operation->getStateOptions()) { - if ($options instanceof DoctrineOptions && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } - - if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { - $entityClass = $options->getDocumentClass(); - } + foreach ($filter->getDescription($entityClass) as $name => $description) { + $parameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $filterId); } + } - foreach ($filter->getDescription($entityClass) as $name => $data) { - if (isset($data['swagger'])) { - trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter::class, $operation->getShortName())); - } + return $parameters; + } - if (!isset($data['openapi']) || $data['openapi'] instanceof Parameter) { - $schema = $data['schema'] ?? []; + private function getFilterClass(HttpOperation $operation): ?string + { + $entityClass = $operation->getClass(); + if ($options = $operation->getStateOptions()) { + if ($options instanceof DoctrineOptions && $options->getEntityClass()) { + return $options->getEntityClass(); + } - if (isset($data['type']) && \in_array($data['type'] ?? null, Type::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->jsonSchemaTypeFactory ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)); - } + if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { + return $options->getDocumentClass(); + } + } - if (!isset($schema['type'])) { - $schema['type'] = 'string'; - } + return $entityClass; + } - $style = 'array' === ($schema['type'] ?? null) && \in_array( - $data['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], - true - ) ? 'deepObject' : 'form'; + private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter + { + if (isset($description['swagger'])) { + trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter, $shortName)); + } - $parameter = isset($data['openapi']) && $data['openapi'] instanceof Parameter ? $data['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $data['is_collection'] ?? false); + if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { + $schema = $description['schema'] ?? []; - if ('' === $parameter->getDescription() && ($description = $data['description'] ?? '')) { - $parameter = $parameter->withDescription($description); - } + if (isset($description['type']) && \in_array($description['type'], Type::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->jsonSchemaTypeFactory ? $this->jsonSchemaTypeFactory->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)); + } - if (false === $parameter->getRequired() && false !== ($required = $data['required'] ?? false)) { - $parameter = $parameter->withRequired($required); - } + if (!isset($schema['type'])) { + $schema['type'] = 'string'; + } - $parameters[] = $parameter->withSchema($schema); - continue; - } + $style = 'array' === ($schema['type'] ?? null) && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form'; - trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter::class, $operation->getShortName())); - if ($this->jsonSchemaTypeFactory) { - $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false), 'openapi') : ['type' => 'string']); - } else { - $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']); - } + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false); - $parameters[] = new Parameter( - $name, - 'query', - $data['description'] ?? '', - $data['required'] ?? false, - $data['openapi']['deprecated'] ?? false, - $data['openapi']['allowEmptyValue'] ?? true, - $schema, - 'array' === $schema['type'] && \in_array( - $data['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], - true - ) ? 'deepObject' : 'form', - $data['openapi']['explode'] ?? ('array' === $schema['type']), - $data['openapi']['allowReserved'] ?? false, - $data['openapi']['example'] ?? null, - isset( - $data['openapi']['examples'] - ) ? new \ArrayObject($data['openapi']['examples']) : null - ); + if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) { + $parameter = $parameter->withDescription($str); + } + + if (false === $parameter->getRequired() && false !== ($required = $description['required'] ?? false)) { + $parameter = $parameter->withRequired($required); } + + return $parameter->withSchema($schema); } - return $parameters; + trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter, $shortName)); + if ($this->jsonSchemaTypeFactory) { + $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false), 'openapi') : ['type' => 'string']); + } else { + $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : ['type' => 'string']); + } + + return new Parameter( + $name, + 'query', + $description['description'] ?? '', + $description['required'] ?? false, + $description['openapi']['deprecated'] ?? false, + $description['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form', + $description['openapi']['explode'] ?? ('array' === $schema['type']), + $description['openapi']['allowReserved'] ?? false, + $description['openapi']['example'] ?? null, + isset( + $description['openapi']['examples'] + ) ? new \ArrayObject($description['openapi']['examples']) : null + ); } private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 0541cfd5304..1d3511df8e6 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -888,7 +888,8 @@ public function testInvoke(): void 'type' => 'string', 'enum' => ['asc', 'desc'], ]), - ] + ], + deprecated: false ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); diff --git a/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.php b/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.php new file mode 100644 index 00000000000..ab812e30227 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptions.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\ApiResource; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\State\CollectionProvider; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; + +#[GetCollection( + uriTemplate: 'filter_with_state_options', + stateOptions: new Options(entityClass: FilterWithStateOptionsEntity::class), + parameters: ['date' => new QueryParameter(filter: 'filter_with_state_options_date', property: 'dummyDate')], + provider: CollectionProvider::class +)] +#[ApiFilter(DateFilter::class, alias: 'filter_with_state_options_date', properties: ['dummyDate' => DateFilter::EXCLUDE_NULL])] +final class FilterWithStateOptions +{ + public function __construct(public readonly string $id, public readonly \DateTimeImmutable $dummyDate, public readonly string $name) + { + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php b/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php new file mode 100644 index 00000000000..45f05f491bd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsEntity.php @@ -0,0 +1,32 @@ + + * + * 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; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class FilterWithStateOptionsEntity +{ + public function __construct( + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + #[ORM\Column(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $dummyDate = null, + #[ORM\Column(type: 'string', nullable: true)] + public ?string $name = null, + ) { + } +} diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index 4e4347fabf8..ec9b75040b4 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -15,17 +15,23 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; +use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; +use Symfony\Component\DependencyInjection\ContainerInterface; final class DoctrineTest extends ApiTestCase { public function testDoctrineEntitySearchFilter(): void { - $this->recreateSchema(); - $container = static::getContainer(); - $route = 'mongodb' === $container->getParameter('kernel.environment') ? 'search_filter_parameter_document' : 'search_filter_parameter'; + static::bootKernel(); + $container = static::$kernel->getContainer(); + $resource = $this->isMongoDb($container) ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema($resource); + $this->createFixture($resource); + $route = $this->isMongoDb($container) ? 'search_filter_parameter_document' : 'search_filter_parameter'; $response = self::createClient()->request('GET', $route.'?foo=bar'); $a = $response->toArray(); $this->assertCount(2, $a['hydra:member']); @@ -69,9 +75,12 @@ public function testGraphQl(): void $this->markTestSkipped('Parameters are not supported in BC mode.'); } - $this->recreateSchema(); - $container = static::getContainer(); - $object = 'mongodb' === $container->getParameter('kernel.environment') ? 'searchFilterParameterDocuments' : 'searchFilterParameters'; + static::bootKernel(); + $container = static::$kernel->getContainer(); + $resource = $this->isMongoDb($container) ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema($resource); + $this->createFixture($resource); + $object = $this->isMongoDb($container) ? 'searchFilterParameterDocuments' : 'searchFilterParameters'; $response = self::createClient()->request('POST', '/graphql', ['json' => [ 'query' => \sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object), ]]); @@ -93,31 +102,54 @@ public function testGraphQl(): void $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); } - /** - * @param array $options kernel options - */ - private function recreateSchema(array $options = []): void + public function testStateOptions(): void { - self::bootKernel($options); + static::bootKernel(); + $container = static::$kernel->getContainer(); + $this->recreateSchema(FilterWithStateOptionsEntity::class); + $registry = $this->isMongoDb($container) ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + $d = new \DateTimeImmutable(); + $manager->persist(new FilterWithStateOptionsEntity(dummyDate: $d, name: 'current')); + $manager->persist(new FilterWithStateOptionsEntity(name: 'null')); + $manager->persist(new FilterWithStateOptionsEntity(dummyDate: $d->add(\DateInterval::createFromDateString('1 day')), name: 'after')); + $manager->flush(); + $response = self::createClient()->request('GET', 'filter_with_state_options?date[before]='.$d->format('Y-m-d')); + $a = $response->toArray(); + $this->assertEquals('/filter_with_state_options{?date[before],date[strictly_before],date[after],date[strictly_after]}', $a['hydra:search']['hydra:template']); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals('current', $a['hydra:member'][0]['name']); + $response = self::createClient()->request('GET', 'filter_with_state_options?date[strictly_after]='.$d->format('Y-m-d')); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals('after', $a['hydra:member'][0]['name']); + } - $container = static::getContainer(); - $registry = $this->getContainer()->get('mongodb' === $container->getParameter('kernel.environment') ? 'doctrine_mongodb' : 'doctrine'); - $resource = 'mongodb' === $container->getParameter('kernel.environment') ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + private function recreateSchema(string $resourceClass): void + { + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDb($container) ? $container->get('doctrine_mongodb') : $container->get('doctrine'); $manager = $registry->getManager(); if ($manager instanceof EntityManagerInterface) { - $classes = $manager->getClassMetadata($resource); + $classes = $manager->getClassMetadata($resourceClass); $schemaTool = new SchemaTool($manager); @$schemaTool->dropSchema([$classes]); @$schemaTool->createSchema([$classes]); - } else { + } elseif ($manager instanceof DocumentManager) { $schemaManager = $manager->getSchemaManager(); $schemaManager->dropCollections(); } + } + public function createFixture(string $resourceClass): void + { + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDb($container) ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); $date = new \DateTimeImmutable('2024-01-21'); foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { - $s = new $resource(); + $s = new $resourceClass(); $s->setFoo($t); if ('bar' === $t) { $s->setCreatedAt($date); @@ -128,4 +160,9 @@ private function recreateSchema(array $options = []): void } $manager->flush(); } + + private function isMongoDb(ContainerInterface $container): bool + { + return 'mongodb' === $container->getParameter('kernel.environment'); + } } From 89269f66911630ed87486e574ffc39adf914bcfa Mon Sep 17 00:00:00 2001 From: Simon <1218015+simondaigre@users.noreply.github.com> Date: Sat, 26 Oct 2024 09:08:32 +0200 Subject: [PATCH 2/3] chore: missing .gitattributes (#6757) --- src/Hal/.gitattributes | 5 +++++ src/HttpCache/.gitattributes | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 src/Hal/.gitattributes create mode 100644 src/HttpCache/.gitattributes diff --git a/src/Hal/.gitattributes b/src/Hal/.gitattributes new file mode 100644 index 00000000000..801f2080d71 --- /dev/null +++ b/src/Hal/.gitattributes @@ -0,0 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/src/HttpCache/.gitattributes b/src/HttpCache/.gitattributes new file mode 100644 index 00000000000..801f2080d71 --- /dev/null +++ b/src/HttpCache/.gitattributes @@ -0,0 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/Tests export-ignore +/phpunit.xml.dist export-ignore From 8bdf04409a4e873c889d22c4e05a3a81137f2a57 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 26 Oct 2024 09:20:01 +0200 Subject: [PATCH 3/3] doc: changelog 3.4.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8577f255225..1bbd7c32d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v3.4.5 + +### Bug fixes + +* [fc8fa00a1](https://github.com/api-platform/core/commit/fc8fa00a19320b65547a60537261959c11f8e6a8) fix(hydra): iri template when using query parameter (#6742) + ## v3.4.4 ### Bug fixes