Skip to content

Commit a24f7d1

Browse files
author
Thibaut Cholley
committed
refactor: introduce getInputClass/getOutputClass
1 parent 400463f commit a24f7d1

38 files changed

Lines changed: 879 additions & 116 deletions

src/GraphQl/Serializer/SerializerContextBuilder.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ public function create(?string $resourceClass, Operation $operation, array $reso
4040
}
4141

4242
$context['operation'] = $operation;
43-
if ($operation->getInput()) {
44-
$context['input'] = $operation->getInput();
43+
if (null !== ($inputClass = $operation->getInputClass()) && $inputClass !== $resourceClass) {
44+
$context['input'] = ['class' => $inputClass];
4545
}
46-
if ($operation->getOutput()) {
47-
$context['output'] = $operation->getOutput();
46+
if (null !== ($outputClass = $operation->getOutputClass()) && $outputClass !== $resourceClass) {
47+
$context['output'] = ['class' => $outputClass];
4848
}
4949
$context = $normalization ? array_merge($operation->getNormalizationContext() ?? [], $context) : array_merge($operation->getDenormalizationContext() ?? [], $context);
5050

src/GraphQl/State/Provider/DenormalizeProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
3737
{
3838
$data = $this->decorated->provide($operation, $uriVariables, $context);
3939

40-
if (!($operation->canDeserialize() ?? true) || (!$operation instanceof Mutation)) {
40+
if (!($operation->canDeserialize() ?? true) || (!$operation instanceof Mutation) || null === ($inputClass = $operation->getInputClass())) {
4141
return $data;
4242
}
4343

@@ -47,7 +47,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
4747
$denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data;
4848
}
4949

50-
$item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemNormalizer::FORMAT, $denormalizationContext);
50+
$item = $this->denormalizer->denormalize($context['args']['input'], $inputClass, ItemNormalizer::FORMAT, $denormalizationContext);
5151

5252
if (!\is_object($item)) {
5353
throw new \UnexpectedValueException('Expected item to be an object.');

src/GraphQl/State/Provider/ResolverProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
4444
$item = $queryResolver($item, $context);
4545
if (!$operation instanceof CollectionOperationInterface) {
4646
// The item retrieved can be of another type when using an identifier (see Relay Nodes at query.feature:23)
47-
$this->getResourceClass($item, $operation->getOutput()['class'] ?? $operation->getClass(), \sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.');
47+
$this->getResourceClass($item, $operation->getOutputClass() , \sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.');
4848
}
4949

5050
return $item;

src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ public function testProvideNotCalledWithQuery(): void
7171
$provider->provide($operation, [], $context);
7272
}
7373

74+
/**
75+
* Regression test: input explicitly disabled (`input: false`) combined with an explicit
76+
* `deserialize: true` (a contradictory config that doesn't force canDeserialize() to false)
77+
* must not crash `denormalize()` with a null target type — it should bail out instead.
78+
*/
79+
public function testProvideNotCalledWhenInputDisabledDespiteDeserializeTrue(): void
80+
{
81+
$objectToPopulate = new \stdClass();
82+
$context = ['args' => ['input' => ['test']]];
83+
$operation = new Mutation(class: \stdClass::class, input: false, deserialize: true);
84+
$decorated = $this->createMock(ProviderInterface::class);
85+
$decorated->expects($this->once())->method('provide')->willReturn($objectToPopulate);
86+
$denormalizer = $this->createMock(DenormalizerInterface::class);
87+
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
88+
$serializerContextBuilder->expects($this->never())->method('create');
89+
$denormalizer->expects($this->never())->method('denormalize');
90+
$provider = new DenormalizeProvider($decorated, $denormalizer, $serializerContextBuilder);
91+
$provider->provide($operation, [], $context);
92+
}
93+
7494
public function testProvideNotCalledWithoutDeserialize(): void
7595
{
7696
$objectToPopulate = new \stdClass();

src/GraphQl/Type/TypeBuilder.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -311,15 +311,13 @@ private function getQueryOperation(ResourceMetadataCollection $resourceMetadataC
311311
private function getResourceObjectTypeConfiguration(string $shortName, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, array $context = []): InputObjectType|ObjectType
312312
{
313313
$operationName = $operation->getName();
314-
$resourceClass = $operation->getClass();
315314
$input = $context['input'];
316315
$depth = $context['depth'] ?? 0;
317316
$wrapped = $context['wrapped'] ?? false;
318317

319-
$ioMetadata = $input ? $operation->getInput() : $operation->getOutput();
320-
if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) {
321-
$resourceClass = $ioMetadata['class'];
322-
}
318+
$resourceClass = $input ? $operation->getInputClass() : $operation->getOutputClass();
319+
// Always pass ['class' => ...] so FieldsBuilder can detect disabled output/input (null class).
320+
$ioMetadata = ['class' => $resourceClass];
323321

324322
$wrapData = !$wrapped && ($operation instanceof Mutation || $operation instanceof Subscription) && !$input && $depth < 1;
325323

@@ -343,7 +341,7 @@ private function getResourceObjectTypeConfiguration(string $shortName, ResourceM
343341

344342
// The query type can only be reused when both operations produce the same output class.
345343
// A mutation declaring its own output class must expose that class on its payload.
346-
if (!$useWrappedType && ($operation->getOutput()['class'] ?? null) !== ($queryOperation?->getOutput()['class'] ?? null)) {
344+
if (!$useWrappedType && $operation->getOutputClass() !== ($queryOperation?->getOutputClass())) {
347345
$useWrappedType = true;
348346
}
349347

src/Hydra/Serializer/DocumentationNormalizer.php

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,11 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource
216216
continue;
217217
}
218218

219-
$inputMetadata = $operation->getInput();
220-
if (null !== $inputClass = $inputMetadata['class'] ?? null) {
219+
if (null !== ($inputClass = $operation->getInputClass())) {
221220
$classes[$inputClass] = true;
222221
}
223222

224-
$outputMetadata = $operation->getOutput();
225-
if (null !== $outputClass = $outputMetadata['class'] ?? null) {
223+
if (null !== ($outputClass = $operation->getOutputClass())) {
226224
$classes[$outputClass] = true;
227225
}
228226
}
@@ -288,11 +286,8 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho
288286
}
289287

290288
$shortName = $operation->getShortName();
291-
$inputMetadata = $operation->getInput() ?? [];
292-
$outputMetadata = $operation->getOutput() ?? [];
293-
294-
$inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false;
295-
$outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false;
289+
$inputClass = $operation->getInputClass();
290+
$outputClass = $operation->getOutputClass();
296291

297292
if ('GET' === $method && $operation instanceof CollectionOperationInterface) {
298293
$hydraOperation += [

src/Hydra/State/JsonStreamerProcessor.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
7575
|| !($request = $context['request'] ?? null)
7676
|| !$operation->getJsonStream()
7777
|| 'jsonld' !== $request->getRequestFormat()
78+
|| null === ($outputClass = $operation->getOutputClass())
7879
) {
7980
return $this->processor?->process($data, $operation, $uriVariables, $context);
8081
}
@@ -100,13 +101,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
100101

101102
$data = $this->jsonStreamer->write(
102103
$collection,
103-
Type::generic(Type::object($collection::class), Type::object($operation->getClass())),
104+
Type::generic(Type::object($collection::class), Type::object($outputClass)),
104105
['data' => $data, 'operation' => $operation],
105106
);
106107
} else {
107108
$data = $this->jsonStreamer->write(
108109
$data,
109-
Type::object($operation->getClass()),
110+
Type::object($outputClass),
110111
['data' => $data, 'operation' => $operation],
111112
);
112113
}

src/Hydra/State/JsonStreamerProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
3535

3636
$data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data');
3737

38-
if (!$operation->canDeserialize() || 'jsonld' !== $request->attributes->get('input_format')) {
38+
if (!$operation->canDeserialize() || 'jsonld' !== $request->attributes->get('input_format') || null === ($inputClass = $operation->getInputClass())) {
3939
return $data;
4040
}
4141

42-
$data = $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass()));
42+
$data = $this->jsonStreamReader->read($request->getContent(true), Type::object($inputClass));
4343
$context['request']->attributes->set('deserialized', true);
4444

4545
if (\PHP_VERSION_ID > 80400) {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Hydra\Tests\State;
15+
16+
use ApiPlatform\Hydra\State\JsonStreamerProvider;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\State\ProviderInterface;
19+
use PHPUnit\Framework\TestCase;
20+
use Symfony\Component\HttpFoundation\Request;
21+
use Symfony\Component\JsonStreamer\StreamReaderInterface;
22+
use Symfony\Component\TypeInfo\Type;
23+
24+
class JsonStreamerProviderTest extends TestCase
25+
{
26+
public function testProvideReadsJsonLdContentIntoInputClass(): void
27+
{
28+
$data = new \stdClass();
29+
$operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true);
30+
$request = new Request(content: '{}');
31+
$request->attributes->set('input_format', 'jsonld');
32+
33+
$jsonStreamReader = $this->createMock(StreamReaderInterface::class);
34+
$jsonStreamReader->expects($this->once())
35+
->method('read')
36+
->with($this->isType('resource'), $this->equalTo(Type::object(\stdClass::class)))
37+
->willReturn($data);
38+
39+
$decorated = $this->createMock(ProviderInterface::class);
40+
$decorated->method('provide')->willReturn(null);
41+
42+
$provider = new JsonStreamerProvider($decorated, $jsonStreamReader);
43+
$result = $provider->provide($operation, [], ['request' => $request]);
44+
45+
$this->assertSame($data, $result);
46+
$this->assertTrue($request->attributes->get('deserialized'));
47+
}
48+
49+
public function testProvideBypassesWhenNotJsonLdFormat(): void
50+
{
51+
$data = new \stdClass();
52+
$operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true);
53+
$request = new Request();
54+
$request->attributes->set('input_format', 'json');
55+
$request->attributes->set('data', $data);
56+
57+
$jsonStreamReader = $this->createMock(StreamReaderInterface::class);
58+
$jsonStreamReader->expects($this->never())->method('read');
59+
60+
$decorated = $this->createMock(ProviderInterface::class);
61+
$decorated->method('provide')->willReturn($data);
62+
63+
$provider = new JsonStreamerProvider($decorated, $jsonStreamReader);
64+
$result = $provider->provide($operation, [], ['request' => $request]);
65+
66+
$this->assertSame($data, $result);
67+
}
68+
69+
/**
70+
* Regression test: input explicitly disabled (`input: false`) combined with an explicit
71+
* `deserialize: true` (a contradictory config) must not crash `Type::object(null)` — it should
72+
* bypass instead.
73+
*/
74+
public function testProvideBypassesWhenInputDisabledDespiteDeserializeTrue(): void
75+
{
76+
$data = new \stdClass();
77+
$operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true, input: false);
78+
$request = new Request(content: '{}');
79+
$request->attributes->set('input_format', 'jsonld');
80+
81+
$jsonStreamReader = $this->createMock(StreamReaderInterface::class);
82+
$jsonStreamReader->expects($this->never())->method('read');
83+
84+
$decorated = $this->createMock(ProviderInterface::class);
85+
$decorated->method('provide')->willReturn($data);
86+
87+
$provider = new JsonStreamerProvider($decorated, $jsonStreamReader);
88+
$result = $provider->provide($operation, [], ['request' => $request]);
89+
90+
$this->assertSame($data, $result);
91+
}
92+
}

src/JsonSchema/ResourceMetadataTrait.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@ trait ResourceMetadataTrait
2929

3030
private function findOutputClass(string $className, string $type, Operation $operation, ?array $serializerContext): ?string
3131
{
32-
$inputOrOutput = ['class' => $className];
33-
$inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
32+
$isOutput = Schema::TYPE_OUTPUT === $type;
33+
// Use hasExplicit* to distinguish "explicitly disabled" (null) from "no explicit setting" ($className fallback).
34+
if ($isOutput ? $operation->hasExplicitOutputClass() : $operation->hasExplicitInputClass()) {
35+
$resourceClass = $isOutput ? $operation->getOutputClass() : $operation->getInputClass();
36+
} else {
37+
$resourceClass = $className;
38+
}
3439
$forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false;
3540

36-
return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
41+
return $forceSubschema ? ($resourceClass ?? $operation->getClass()) : $resourceClass;
3742
}
3843

3944
private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext, ?string $format = null): Operation

0 commit comments

Comments
 (0)