Skip to content

Commit 406ae36

Browse files
committed
Merge 4.3
2 parents d36c14c + ad027fb commit 406ae36

21 files changed

Lines changed: 442 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,55 @@ jobs:
10781078
- name: Run Behat tests
10791079
run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction
10801080

1081+
# remove once behat can be installed with symfony 8.1
1082+
phpunit-symfony-edge:
1083+
name: PHPUnit (PHP ${{ matrix.php }}) (Symfony 8.1)
1084+
runs-on: ubuntu-latest
1085+
timeout-minutes: 20
1086+
continue-on-error: true
1087+
strategy:
1088+
matrix:
1089+
php:
1090+
- '8.5'
1091+
fail-fast: false
1092+
steps:
1093+
- name: Checkout
1094+
uses: actions/checkout@v6
1095+
- name: Setup PHP
1096+
uses: shivammathur/setup-php@v2
1097+
with:
1098+
php-version: ${{ matrix.php }}
1099+
tools: pecl, composer
1100+
extensions: intl, bcmath, curl, openssl, mbstring
1101+
coverage: none
1102+
ini-values: memory_limit=-1
1103+
- name: Get composer cache directory
1104+
id: composercache
1105+
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
1106+
- name: Allow unstable project dependencies
1107+
run: composer config minimum-stability dev
1108+
- name: Drop Behat dev dependencies (incompatible with Symfony 8.1)
1109+
run: composer remove --no-update --no-interaction --dev behat/behat behat/mink soyuka/contexts friends-of-behat/symfony-extension friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension
1110+
- name: Force Symfony 8.1 dev for framework-bundle and json-streamer
1111+
run: composer require --dev --no-update --no-interaction "symfony/framework-bundle:8.1.x-dev" "symfony/json-streamer:8.1.x-dev"
1112+
- name: Cache dependencies
1113+
uses: actions/cache@v5
1114+
with:
1115+
path: ${{ steps.composercache.outputs.dir }}
1116+
key: ${{ runner.os }}-composer-symfony-edge-${{ hashFiles('**/composer.json') }}
1117+
restore-keys: ${{ runner.os }}-composer-symfony-edge-
1118+
- name: Remove cache
1119+
run: rm -Rf tests/Fixtures/app/var/cache/*
1120+
- name: Update project dependencies
1121+
run: |
1122+
composer global require soyuka/pmu
1123+
composer global config allow-plugins.soyuka/pmu true --no-interaction
1124+
composer global link .
1125+
- name: Clear test app cache
1126+
run: tests/Fixtures/app/console cache:clear --ansi
1127+
- name: Run PHPUnit tests
1128+
run: vendor/bin/phpunit
1129+
10811130
windows-behat:
10821131
name: Windows Behat (PHP ${{ matrix.php }}) (SQLite)
10831132
runs-on: windows-latest

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## v4.3.5
4+
5+
### Bug fixes
6+
7+
* [78c4ddf02](https://github.com/api-platform/core/commit/78c4ddf0241e2af5738fe67e853734d9b4c74566) fix(symfony): Symfony 8.1 compatibility (#7955)
8+
* [14d5e8279](https://github.com/api-platform/core/commit/14d5e8279b646934c0deff18df378d6618a374e8) fix(symfony): api_platform_iris loader misses item Gets and api_genid (#7946)
9+
* [f88b9122f](https://github.com/api-platform/core/commit/f88b9122fe398b69fb16a69e87894076217a0224) fix(hydra): emit hydra:next and hydra:previous on empty cursor-paginated collections (#7961)
10+
* [1721a7366](https://github.com/api-platform/core/commit/1721a736624240801341f151084633a92d8889d0) fix(mcp): jsonld schema handle oneOf and anyOf (#7962)
11+
* [03ca10c17](https://github.com/api-platform/core/commit/03ca10c17ce384e008a344067cc4ba48467d758c) fix(mcp): add title support to Tool
12+
* [30a8e9ef0](https://github.com/api-platform/core/commit/30a8e9ef07d348e6b55d67c750f5ff2eaa2fe2c6) fix(laravel): inject missing dependencies into HydraSchemaFactory (#7963)
13+
* [173dc6631](https://github.com/api-platform/core/commit/173dc663144d44ad16a1f9ac747eeb34a8832c2d) fix(laravel): fall back to resource class when object is null in ResourceAccessChecker (#7948)
14+
* [445529519](https://github.com/api-platform/core/commit/4455295191f36578cefd882c263295fd9fce33a7) fix(laravel): don't cache empty Eloquent attributes for missing table
15+
* [67d7a3dc2](https://github.com/api-platform/core/commit/67d7a3dc2e83b60bc4bd4025cd8602d46b44e2fc) fix(laravel): use lowercase asc/desc for Eloquent orderBy
16+
* [85f6269c3](https://github.com/api-platform/core/commit/85f6269c32d3ae7b14e7df13a6844c61229ee50a) fix(laravel): expose ReDoc/Scalar in docs footer
17+
318
## v4.3.4
419

520
### Bug fixes

src/Hal/Tests/Serializer/ItemNormalizerTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,8 +422,8 @@ public function testMaxDepth(): void
422422
}
423423

424424
/**
425-
* @param array<int,mixed> $context
426-
* @param array<int,mixed> $expected
425+
* @param array<string,mixed> $context
426+
* @param array<string,mixed> $expected
427427
*/
428428
#[DataProvider('getSkipNullToOneRelationCases')]
429429
public function testSkipNullToOneRelation(array $context, array $expected): void

src/Hydra/Serializer/PartialCollectionViewNormalizer.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,59 @@ private function populateDataWithCursorBasedPagination(array $data, array $parse
181181
$data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)), urlGenerationStrategy: $urlGenerationStrategy);
182182
}
183183

184+
if (false === $firstObject && \is_array($cursorPaginationAttribute) && $object instanceof PartialPaginatorInterface) {
185+
$itemsPerPage = $object->getItemsPerPage();
186+
$nextFilters = $this->cursorPaginationFieldsFromUrl($cursorPaginationAttribute, $parsed['parameters'], $itemsPerPage, 1);
187+
$previousFilters = $this->cursorPaginationFieldsFromUrl($cursorPaginationAttribute, $parsed['parameters'], $itemsPerPage, -1);
188+
189+
if (null !== $nextFilters) {
190+
$data[$hydraPrefix.'view'][$hydraPrefix.'next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $nextFilters), urlGenerationStrategy: $urlGenerationStrategy);
191+
}
192+
193+
if (null !== $previousFilters) {
194+
$data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $previousFilters), urlGenerationStrategy: $urlGenerationStrategy);
195+
}
196+
}
197+
184198
return $data;
185199
}
200+
201+
private function cursorPaginationFieldsFromUrl(array $fields, array $parameters, float $itemsPerPage, int $direction): ?array
202+
{
203+
$paginationFilters = [];
204+
205+
foreach ($fields as $field) {
206+
$forwardOperator = 'desc' === strtolower($field['direction']) ? 'lt' : 'gt';
207+
$backwardOperator = 'gt' === $forwardOperator ? 'lt' : 'gt';
208+
209+
if (!\is_array($parameters[$field['field']] ?? null)) {
210+
return null;
211+
}
212+
213+
$currentOperator = (string) array_key_first($parameters[$field['field']]);
214+
if (!\in_array($currentOperator, ['gt', 'lt'], true)) {
215+
return null;
216+
}
217+
218+
$currentValue = (float) current($parameters[$field['field']]);
219+
220+
if ($direction > 0) {
221+
if ($currentOperator === $forwardOperator) {
222+
$newValue = 'gt' === $currentOperator ? $currentValue + $itemsPerPage : $currentValue - $itemsPerPage;
223+
$paginationFilters[$field['field']] = [$currentOperator => (string) $newValue];
224+
} else {
225+
$paginationFilters[$field['field']] = [$forwardOperator => (string) $currentValue];
226+
}
227+
} else {
228+
if ($currentOperator === $backwardOperator) {
229+
$newValue = 'gt' === $currentOperator ? $currentValue + $itemsPerPage : $currentValue - $itemsPerPage;
230+
$paginationFilters[$field['field']] = [$currentOperator => (string) $newValue];
231+
} else {
232+
$paginationFilters[$field['field']] = [$backwardOperator => (string) $currentValue];
233+
}
234+
}
235+
}
236+
237+
return $paginationFilters;
238+
}
186239
}

src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,89 @@ public function testNormalizeWithCursorBasedPagination(): void
108108
);
109109
}
110110

111+
public function testNormalizeWithCursorBasedPaginationEmptyCollection(): void
112+
{
113+
$paginator = new class implements PartialPaginatorInterface, \Iterator {
114+
public function rewind(): void
115+
{
116+
}
117+
118+
public function valid(): bool
119+
{
120+
return false;
121+
}
122+
123+
public function current(): mixed
124+
{
125+
return false;
126+
}
127+
128+
public function key(): mixed
129+
{
130+
return null;
131+
}
132+
133+
public function next(): void
134+
{
135+
}
136+
137+
public function count(): int
138+
{
139+
return 0;
140+
}
141+
142+
public function getCurrentPage(): float
143+
{
144+
return 1.;
145+
}
146+
147+
public function getItemsPerPage(): float
148+
{
149+
return 3.;
150+
}
151+
};
152+
153+
$decoratedNormalizer = $this->createStub(NormalizerInterface::class);
154+
$decoratedNormalizer->method('normalize')->willReturn(['foo' => 'bar']);
155+
156+
$soManyMetadata = new ResourceMetadataCollection(SoMany::class, [
157+
(new ApiResource())->withShortName('SoMany')->withOperations(new Operations([
158+
'get' => (new GetCollection())->withPaginationViaCursor([['field' => 'id', 'direction' => 'desc']]),
159+
])),
160+
]);
161+
162+
$resourceMetadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class);
163+
$resourceMetadataFactory->method('create')->willReturn($soManyMetadata);
164+
165+
$normalizer = new PartialCollectionViewNormalizer(
166+
$decoratedNormalizer,
167+
'_page',
168+
'pagination',
169+
$resourceMetadataFactory
170+
);
171+
172+
self::assertEquals(
173+
[
174+
'foo' => 'bar',
175+
'hydra:view' => [
176+
'@id' => '/?id%5Bgt%5D=10',
177+
'@type' => 'hydra:PartialCollectionView',
178+
'hydra:next' => '/?id%5Blt%5D=10',
179+
'hydra:previous' => '/?id%5Bgt%5D=13',
180+
],
181+
],
182+
$normalizer->normalize(
183+
$paginator,
184+
null,
185+
[
186+
'resource_class' => SoMany::class,
187+
'operation_name' => 'get',
188+
'request_uri' => '/?id%5Bgt%5D=10',
189+
]
190+
)
191+
);
192+
}
193+
111194
private function normalizePaginator(bool $partial = false, bool $cursor = false)
112195
{
113196
$paginatorProphecy = $this->prophesize($partial ? PartialPaginatorInterface::class : PaginatorInterface::class);

src/Laravel/ApiPlatformProvider.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -914,7 +914,9 @@ public function register(): void
914914

915915
return new HydraSchemaFactory(
916916
$app->make(JsonApiSchemaFactory::class),
917-
$defaultContext
917+
$defaultContext,
918+
$app->make(DefinitionNameFactoryInterface::class),
919+
$app->make(ResourceMetadataCollectionFactoryInterface::class),
918920
);
919921
});
920922

src/Mcp/JsonSchema/SchemaFactory.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ public static function resolveNode(array|\ArrayObject $node, array $definitions,
109109
return self::resolveDeep($merged, $definitions, $resolving);
110110
}
111111

112+
// oneOf/anyOf nodes must not receive a type fallback — their type is expressed
113+
// through the sub-schemas. Adding 'type: object' here would break schemas like
114+
// HydraItemBaseSchema's @context, which is oneOf: [string, object].
115+
if (isset($node['oneOf']) || isset($node['anyOf'])) {
116+
return self::resolveDeep($node, $definitions, $resolving);
117+
}
118+
112119
if (!isset($node['type'])) {
113120
$node['type'] = 'object';
114121
}

src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,4 +302,73 @@ public function testUnresolvableRefFallsBackToObject(): void
302302
$arr = $result->getArrayCopy();
303303
$this->assertSame(['type' => 'object'], $arr);
304304
}
305+
306+
public function testOneOfPropertyKeepsSubSchemasWithoutTypeFallback(): void
307+
{
308+
$innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
309+
unset($innerSchema['$schema']);
310+
$definitions = $innerSchema->getDefinitions();
311+
$definitions['Root'] = new \ArrayObject([
312+
'type' => 'object',
313+
'properties' => [
314+
'@context' => new \ArrayObject([
315+
'oneOf' => [
316+
['type' => 'string'],
317+
[
318+
'type' => 'object',
319+
'properties' => [
320+
'@vocab' => ['type' => 'string'],
321+
],
322+
'required' => ['@vocab'],
323+
],
324+
],
325+
]),
326+
],
327+
]);
328+
$innerSchema['$ref'] = '#/definitions/Root';
329+
330+
$inner = $this->createMock(SchemaFactoryInterface::class);
331+
$inner->method('buildSchema')->willReturn($innerSchema);
332+
333+
$factory = new SchemaFactory($inner);
334+
$result = $factory->buildSchema('App\\Dummy', 'json');
335+
336+
$arr = $result->getArrayCopy();
337+
$context = $arr['properties']['@context'];
338+
$this->assertArrayNotHasKey('type', $context);
339+
$this->assertArrayHasKey('oneOf', $context);
340+
$this->assertSame(['type' => 'string'], $context['oneOf'][0]);
341+
$this->assertSame('object', $context['oneOf'][1]['type']);
342+
$this->assertSame(['@vocab' => ['type' => 'string']], $context['oneOf'][1]['properties']);
343+
}
344+
345+
public function testAnyOfPropertyKeepsSubSchemasWithoutTypeFallback(): void
346+
{
347+
$innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
348+
unset($innerSchema['$schema']);
349+
$definitions = $innerSchema->getDefinitions();
350+
$definitions['Root'] = new \ArrayObject([
351+
'type' => 'object',
352+
'properties' => [
353+
'value' => new \ArrayObject([
354+
'anyOf' => [
355+
['type' => 'string'],
356+
['type' => 'integer'],
357+
],
358+
]),
359+
],
360+
]);
361+
$innerSchema['$ref'] = '#/definitions/Root';
362+
363+
$inner = $this->createMock(SchemaFactoryInterface::class);
364+
$inner->method('buildSchema')->willReturn($innerSchema);
365+
366+
$factory = new SchemaFactory($inner);
367+
$result = $factory->buildSchema('App\\Dummy', 'json');
368+
369+
$arr = $result->getArrayCopy();
370+
$value = $arr['properties']['value'];
371+
$this->assertArrayNotHasKey('type', $value);
372+
$this->assertSame([['type' => 'string'], ['type' => 'integer']], $value['anyOf']);
373+
}
305374
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
use Symfony\Component\HttpClient\ScopingHttpClient;
8585
use Symfony\Component\JsonStreamer\JsonStreamWriter;
8686
use Symfony\Component\ObjectMapper\ObjectMapper;
87+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
8788
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
8889
use Symfony\Component\Serializer\Normalizer\NumberNormalizer;
8990
use Symfony\Component\Uid\AbstractUid;
@@ -201,15 +202,18 @@ public function load(array $configs, ContainerBuilder $container): void
201202
$this->registerLinkSecurityConfiguration($loader, $config);
202203
$this->registerJsonStreamerConfiguration($container, $loader, $formats, $config);
203204

204-
// TranslationExtractCommand was introduced in framework-bundle/7.3 with the object mapper service
205-
if (class_exists(ObjectMapper::class) && class_exists(TranslationExtractCommand::class)) {
205+
// TranslationExtractCommand was introduced in framework-bundle/7.3 with the object mapper service.
206+
// willBeAvailable mirrors FrameworkBundle's own gate: when symfony/object-mapper is in dev-only,
207+
// FrameworkBundle skips object_mapper.php and the "object_mapper" service we alias to does not exist.
208+
if (class_exists(ObjectMapper::class) && class_exists(TranslationExtractCommand::class) && ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) {
206209
$loader->load('state/object_mapper.php');
207210
$loader->load($config['use_symfony_listeners'] ? 'symfony/object_mapper.php' : 'state/object_mapper_processor.php');
208211
}
209212

210213
$container->setParameter('api_platform.mcp.format', $config['mcp']['format'] ?? null);
211214

212-
if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class)) {
215+
// McpToolProvider requires Symfony's object_mapper service; mirror FrameworkBundle's gate so we don't try to wire it when object-mapper is dev-only.
216+
if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class) && ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) {
213217
$loader->load('mcp/mcp.php');
214218
$loader->load($config['use_symfony_listeners'] ? 'mcp/events.php' : 'mcp/state.php');
215219
}

src/Symfony/Bundle/Resources/config/json_streamer/common.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\JsonLd\JsonStreamer\ValueTransformer\IriValueTransformer;
1818
use ApiPlatform\JsonLd\JsonStreamer\ValueTransformer\TypeValueTransformer;
1919
use ApiPlatform\JsonLd\JsonStreamer\WritePropertyMetadataLoader;
20+
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\DeprecateJsonStreamerValueTransformerTagPass;
2021
use Symfony\Component\JsonStreamer\CacheWarmer\LazyGhostCacheWarmer;
2122
use Symfony\Component\JsonStreamer\JsonStreamReader;
2223
use Symfony\Component\JsonStreamer\JsonStreamWriter;
@@ -77,9 +78,9 @@
7778
->args([service('api_platform.router')])
7879
->tag('json_streamer.value_transformer');
7980

80-
if (class_exists(DateTimeValueObjectTransformer::class)) {
81+
// FrameworkBundle 8.1+ registers DateTimeValueObjectTransformer itself; skip to avoid duplicate registration and TransformerPass validation breakage (issue #7954).
82+
if (class_exists(DateTimeValueObjectTransformer::class) && !class_exists(DeprecateJsonStreamerValueTransformerTagPass::class)) {
8183
$services->set('api_platform.jsonld.json_streamer.value_transformer.date_time', DateTimeValueObjectTransformer::class)
82-
->tag('json_streamer.value_transformer', ['key' => \DateTimeInterface::class])
8384
->tag('json_streamer.value_object_transformer');
8485
}
8586
};

0 commit comments

Comments
 (0)