From f41921be0db7176544f9bb7317d1fcc327eb9e64 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 14:52:30 +0200 Subject: [PATCH 1/2] doc: changelog 4.4.0-alpha.3 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 227cd508a6..aa60f3344d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v4.4.0-alpha.3 + +### Bug fixes + +* [f8c217283](https://github.com/api-platform/core/commit/f8c217283659dc80985e85c5570e113e1eba6482) fix(state): correct composer "conflicts" key to "conflict" (#8400) + +### Dependencies + +* Require `symfony/*` `^7.4 || ^8.0` across all components; drop support for Symfony 6.4 and 7.0–7.3 (#8397) + ## v4.4.0-alpha.2 ### Bug fixes From b586ee99cf8271aca7f08bd4f02a4b372e542e1d Mon Sep 17 00:00:00 2001 From: Thibaut Cholley Date: Thu, 25 Jun 2026 07:30:29 +0200 Subject: [PATCH 2/2] refactor: introduce getInputClass/getOutputClass # Conflicts: # src/GraphQl/State/Provider/DenormalizeProvider.php --- .../Serializer/SerializerContextBuilder.php | 8 +- .../State/Provider/DenormalizeProvider.php | 4 +- .../State/Provider/ResolverProvider.php | 2 +- .../Provider/DenormalizeProviderTest.php | 20 +++ src/GraphQl/Type/TypeBuilder.php | 10 +- .../Serializer/DocumentationNormalizer.php | 13 +- src/Hydra/State/JsonStreamerProcessor.php | 5 +- src/Hydra/State/JsonStreamerProvider.php | 4 +- .../Tests/State/JsonStreamerProviderTest.php | 92 +++++++++++++ src/JsonSchema/ResourceMetadataTrait.php | 11 +- src/Mcp/Capability/Registry/Loader.php | 18 ++- src/Mcp/State/ToolProvider.php | 7 +- .../Tests/Capability/Registry/LoaderTest.php | 48 +++++++ src/Mcp/Tests/State/ToolProviderTest.php | 72 +++++++++++ src/Metadata/ApiResource.php | 10 ++ src/Metadata/Metadata.php | 121 +++++++++++++++++- src/Metadata/Operation.php | 10 ++ ...utputResourceMetadataCollectionFactory.php | 49 +++---- .../ObjectMapperMetadataCollectionFactory.php | 17 ++- ...tResourceMetadataCollectionFactoryTest.php | 79 ++++++++++-- ...ectMapperMetadataCollectionFactoryTest.php | 112 ++++++++++++++++ src/OpenApi/Factory/OpenApiFactory.php | 4 +- .../Tests/Factory/OpenApiFactoryTest.php | 4 +- src/Serializer/InputOutputMetadataTrait.php | 8 ++ src/Serializer/SerializerContextBuilder.php | 4 +- .../State/JsonStreamerProcessor.php | 5 +- src/Serializer/State/JsonStreamerProvider.php | 4 +- .../Tests/State/JsonStreamerProviderTest.php | 92 +++++++++++++ .../Processor/ObjectMapperInputProcessor.php | 2 +- .../Processor/ObjectMapperOutputProcessor.php | 2 +- src/State/Processor/ObjectMapperProcessor.php | 6 +- .../Provider/ContentNegotiationProvider.php | 6 +- src/State/Provider/ObjectMapperProvider.php | 5 +- .../Processor/ObjectMapperProcessorTest.php | 96 ++++++++++++++ src/State/Util/HttpResponseHeadersTrait.php | 8 +- src/State/Util/HttpResponseStatusTrait.php | 3 +- .../State/DummyCollectionDtoProvider.php | 2 +- .../Provider/ObjectMapperProviderTest.php | 32 +++++ 38 files changed, 879 insertions(+), 116 deletions(-) create mode 100644 src/Hydra/Tests/State/JsonStreamerProviderTest.php create mode 100644 src/Mcp/Tests/State/ToolProviderTest.php create mode 100644 src/Metadata/Tests/Resource/Factory/ObjectMapperMetadataCollectionFactoryTest.php create mode 100644 src/Serializer/Tests/State/JsonStreamerProviderTest.php create mode 100644 src/State/Tests/Processor/ObjectMapperProcessorTest.php diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index 9f332c5954..1229f87c0d 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -40,11 +40,11 @@ public function create(?string $resourceClass, Operation $operation, array $reso } $context['operation'] = $operation; - if ($operation->getInput()) { - $context['input'] = $operation->getInput(); + if (null !== ($inputClass = $operation->getInputClass()) && $inputClass !== $resourceClass) { + $context['input'] = ['class' => $inputClass]; } - if ($operation->getOutput()) { - $context['output'] = $operation->getOutput(); + if (null !== ($outputClass = $operation->getOutputClass()) && $outputClass !== $resourceClass) { + $context['output'] = ['class' => $outputClass]; } $context = $normalization ? array_merge($operation->getNormalizationContext() ?? [], $context) : array_merge($operation->getDenormalizationContext() ?? [], $context); diff --git a/src/GraphQl/State/Provider/DenormalizeProvider.php b/src/GraphQl/State/Provider/DenormalizeProvider.php index 45743f5fc6..cb7983ced5 100644 --- a/src/GraphQl/State/Provider/DenormalizeProvider.php +++ b/src/GraphQl/State/Provider/DenormalizeProvider.php @@ -37,7 +37,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c { $data = $this->decorated->provide($operation, $uriVariables, $context); - if (!($operation->canDeserialize() ?? true) || (!$operation instanceof Mutation)) { + if (!($operation->canDeserialize() ?? true) || (!$operation instanceof Mutation) || null === ($inputClass = $operation->getInputClass())) { return $data; } @@ -47,7 +47,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } - $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemDenormalizer::FORMAT, $denormalizationContext); + $item = $this->denormalizer->denormalize($context['args']['input'], $inputClass, ItemDenormalizer::FORMAT, $denormalizationContext); if (!\is_object($item)) { throw new \UnexpectedValueException('Expected item to be an object.'); diff --git a/src/GraphQl/State/Provider/ResolverProvider.php b/src/GraphQl/State/Provider/ResolverProvider.php index 5d51f5c8d5..1cc95468c5 100644 --- a/src/GraphQl/State/Provider/ResolverProvider.php +++ b/src/GraphQl/State/Provider/ResolverProvider.php @@ -44,7 +44,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $item = $queryResolver($item, $context); if (!$operation instanceof CollectionOperationInterface) { // The item retrieved can be of another type when using an identifier (see Relay Nodes at query.feature:23) - $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.'); + $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.'); } return $item; diff --git a/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php b/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php index b123256624..4a0097c0da 100644 --- a/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php +++ b/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php @@ -71,6 +71,26 @@ public function testProvideNotCalledWithQuery(): void $provider->provide($operation, [], $context); } + /** + * Regression test: input explicitly disabled (`input: false`) combined with an explicit + * `deserialize: true` (a contradictory config that doesn't force canDeserialize() to false) + * must not crash `denormalize()` with a null target type — it should bail out instead. + */ + public function testProvideNotCalledWhenInputDisabledDespiteDeserializeTrue(): void + { + $objectToPopulate = new \stdClass(); + $context = ['args' => ['input' => ['test']]]; + $operation = new Mutation(class: \stdClass::class, input: false, deserialize: true); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn($objectToPopulate); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->never())->method('create'); + $denormalizer->expects($this->never())->method('denormalize'); + $provider = new DenormalizeProvider($decorated, $denormalizer, $serializerContextBuilder); + $provider->provide($operation, [], $context); + } + public function testProvideNotCalledWithoutDeserialize(): void { $objectToPopulate = new \stdClass(); diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index a0f346a54d..74b86b9d5e 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -311,15 +311,13 @@ private function getQueryOperation(ResourceMetadataCollection $resourceMetadataC private function getResourceObjectTypeConfiguration(string $shortName, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, array $context = []): InputObjectType|ObjectType { $operationName = $operation->getName(); - $resourceClass = $operation->getClass(); $input = $context['input']; $depth = $context['depth'] ?? 0; $wrapped = $context['wrapped'] ?? false; - $ioMetadata = $input ? $operation->getInput() : $operation->getOutput(); - if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) { - $resourceClass = $ioMetadata['class']; - } + $resourceClass = $input ? $operation->getInputClass() : $operation->getOutputClass(); + // Always pass ['class' => ...] so FieldsBuilder can detect disabled output/input (null class). + $ioMetadata = ['class' => $resourceClass]; $wrapData = !$wrapped && ($operation instanceof Mutation || $operation instanceof Subscription) && !$input && $depth < 1; @@ -343,7 +341,7 @@ private function getResourceObjectTypeConfiguration(string $shortName, ResourceM // The query type can only be reused when both operations produce the same output class. // A mutation declaring its own output class must expose that class on its payload. - if (!$useWrappedType && ($operation->getOutput()['class'] ?? null) !== ($queryOperation?->getOutput()['class'] ?? null)) { + if (!$useWrappedType && $operation->getOutputClass() !== ($queryOperation?->getOutputClass())) { $useWrappedType = true; } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 14fbee9c03..349c6c3a36 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -212,13 +212,11 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource continue; } - $inputMetadata = $operation->getInput(); - if (null !== $inputClass = $inputMetadata['class'] ?? null) { + if (null !== ($inputClass = $operation->getInputClass())) { $classes[$inputClass] = true; } - $outputMetadata = $operation->getOutput(); - if (null !== $outputClass = $outputMetadata['class'] ?? null) { + if (null !== ($outputClass = $operation->getOutputClass())) { $classes[$outputClass] = true; } } @@ -284,11 +282,8 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho } $shortName = $operation->getShortName(); - $inputMetadata = $operation->getInput() ?? []; - $outputMetadata = $operation->getOutput() ?? []; - - $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; - $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; + $inputClass = $operation->getInputClass(); + $outputClass = $operation->getOutputClass(); if ('GET' === $method && $operation instanceof CollectionOperationInterface) { $hydraOperation += [ diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index 6b461c187f..04a162604d 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -76,6 +76,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !($request = $context['request'] ?? null) || !$operation->getJsonStream() || 'jsonld' !== $request->getRequestFormat() + || null === ($outputClass = $operation->getOutputClass()) ) { return $this->processor?->process($data, $operation, $uriVariables, $context); } @@ -111,13 +112,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $data = $this->jsonStreamer->write( $collection, - Type::generic(Type::object($collection::class), Type::object($operation->getClass())), + Type::generic(Type::object($collection::class), Type::object($outputClass)), ['data' => $data, 'operation' => $operation], ); } else { $data = $this->jsonStreamer->write( $data, - Type::object($operation->getClass()), + Type::object($outputClass), ['data' => $data, 'operation' => $operation], ); } diff --git a/src/Hydra/State/JsonStreamerProvider.php b/src/Hydra/State/JsonStreamerProvider.php index 696d8c1edb..c858dd967c 100644 --- a/src/Hydra/State/JsonStreamerProvider.php +++ b/src/Hydra/State/JsonStreamerProvider.php @@ -35,11 +35,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data'); - if (!$operation->canDeserialize() || 'jsonld' !== $request->attributes->get('input_format')) { + if (!$operation->canDeserialize() || 'jsonld' !== $request->attributes->get('input_format') || null === ($inputClass = $operation->getInputClass())) { return $data; } - $data = $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass())); + $data = $this->jsonStreamReader->read($request->getContent(true), Type::object($inputClass)); $context['request']->attributes->set('deserialized', true); if (\PHP_VERSION_ID > 80400) { diff --git a/src/Hydra/Tests/State/JsonStreamerProviderTest.php b/src/Hydra/Tests/State/JsonStreamerProviderTest.php new file mode 100644 index 0000000000..94e46de510 --- /dev/null +++ b/src/Hydra/Tests/State/JsonStreamerProviderTest.php @@ -0,0 +1,92 @@ + + * + * 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\Hydra\Tests\State; + +use ApiPlatform\Hydra\State\JsonStreamerProvider; +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\TypeInfo\Type; + +class JsonStreamerProviderTest extends TestCase +{ + public function testProvideReadsJsonLdContentIntoInputClass(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true); + $request = new Request(content: '{}'); + $request->attributes->set('input_format', 'jsonld'); + + $jsonStreamReader = $this->createMock(StreamReaderInterface::class); + $jsonStreamReader->expects($this->once()) + ->method('read') + ->with($this->isType('resource'), $this->equalTo(Type::object(\stdClass::class))) + ->willReturn($data); + + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new JsonStreamerProvider($decorated, $jsonStreamReader); + $result = $provider->provide($operation, [], ['request' => $request]); + + $this->assertSame($data, $result); + $this->assertTrue($request->attributes->get('deserialized')); + } + + public function testProvideBypassesWhenNotJsonLdFormat(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true); + $request = new Request(); + $request->attributes->set('input_format', 'json'); + $request->attributes->set('data', $data); + + $jsonStreamReader = $this->createMock(StreamReaderInterface::class); + $jsonStreamReader->expects($this->never())->method('read'); + + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + + $provider = new JsonStreamerProvider($decorated, $jsonStreamReader); + $result = $provider->provide($operation, [], ['request' => $request]); + + $this->assertSame($data, $result); + } + + /** + * Regression test: input explicitly disabled (`input: false`) combined with an explicit + * `deserialize: true` (a contradictory config) must not crash `Type::object(null)` — it should + * bypass instead. + */ + public function testProvideBypassesWhenInputDisabledDespiteDeserializeTrue(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true, input: false); + $request = new Request(content: '{}'); + $request->attributes->set('input_format', 'jsonld'); + + $jsonStreamReader = $this->createMock(StreamReaderInterface::class); + $jsonStreamReader->expects($this->never())->method('read'); + + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + + $provider = new JsonStreamerProvider($decorated, $jsonStreamReader); + $result = $provider->provide($operation, [], ['request' => $request]); + + $this->assertSame($data, $result); + } +} diff --git a/src/JsonSchema/ResourceMetadataTrait.php b/src/JsonSchema/ResourceMetadataTrait.php index 71988820c7..03b17ead3e 100644 --- a/src/JsonSchema/ResourceMetadataTrait.php +++ b/src/JsonSchema/ResourceMetadataTrait.php @@ -29,11 +29,16 @@ trait ResourceMetadataTrait private function findOutputClass(string $className, string $type, Operation $operation, ?array $serializerContext): ?string { - $inputOrOutput = ['class' => $className]; - $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput); + $isOutput = Schema::TYPE_OUTPUT === $type; + // Use hasExplicit* to distinguish "explicitly disabled" (null) from "no explicit setting" ($className fallback). + if ($isOutput ? $operation->hasExplicitOutputClass() : $operation->hasExplicitInputClass()) { + $resourceClass = $isOutput ? $operation->getOutputClass() : $operation->getInputClass(); + } else { + $resourceClass = $className; + } $forceSubschema = $serializerContext[SchemaFactory::FORCE_SUBSCHEMA] ?? false; - return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); + return $forceSubschema ? ($resourceClass ?? $operation->getClass()) : $resourceClass; } private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext, ?string $format = null): Operation diff --git a/src/Mcp/Capability/Registry/Loader.php b/src/Mcp/Capability/Registry/Loader.php index c88fd1608a..bcf436f2c6 100644 --- a/src/Mcp/Capability/Registry/Loader.php +++ b/src/Mcp/Capability/Registry/Loader.php @@ -49,13 +49,19 @@ public function load(RegistryInterface $registry): void foreach ($metadata as $resource) { foreach ($resource->getMcp() ?? [] as $mcp) { if ($mcp instanceof McpTool) { - $inputClass = $mcp->getInput()['class'] ?? $mcp->getClass(); - $inputFormat = array_key_first($mcp->getInputFormats() ?? ['json' => ['application/json']]); - $inputSchema = $this->schemaFactory->buildSchema($inputClass, $inputFormat, Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + $inputClass = $mcp->getInputClass(); + // Input explicitly disabled: the tool takes no arguments, so reflect that + // honestly with an empty object schema instead of describing a structure + // that isn't actually expected. + if (null !== $inputClass) { + $inputFormat = array_key_first($mcp->getInputFormats() ?? ['json' => ['application/json']]); + $inputSchema = $this->schemaFactory->buildSchema($inputClass, $inputFormat, Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true])->getArrayCopy(); + } else { + $inputSchema = ['type' => 'object', 'properties' => new \stdClass()]; + } $outputSchema = null; - if (false !== $mcp->getStructuredContent()) { - $outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass(); + if (false !== $mcp->getStructuredContent() && null !== ($outputClass = $mcp->getOutputClass())) { $outputFormat = array_key_first($mcp->getOutputFormats() ?? ['json' => ['application/json']]); $outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true])->getArrayCopy(); } @@ -64,7 +70,7 @@ public function load(RegistryInterface $registry): void new Tool( name: $mcp->getName(), title: $mcp->getTitle(), - inputSchema: $inputSchema->getArrayCopy(), + inputSchema: $inputSchema, description: $mcp->getDescription(), annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null, icons: $mcp->getIcons(), diff --git a/src/Mcp/State/ToolProvider.php b/src/Mcp/State/ToolProvider.php index dff2e8874e..0775e1c835 100644 --- a/src/Mcp/State/ToolProvider.php +++ b/src/Mcp/State/ToolProvider.php @@ -35,7 +35,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $data = (object) $context['mcp_data']; - $class = $operation->getInput()['class'] ?? $operation->getClass(); + $class = $operation->getInputClass(); + + // Input explicitly disabled: there's no target shape to map into, return the raw data. + if (null === $class) { + return $data; + } return $this->objectMapper->map($data, $class); } diff --git a/src/Mcp/Tests/Capability/Registry/LoaderTest.php b/src/Mcp/Tests/Capability/Registry/LoaderTest.php index 5e42114449..9a0d3efc50 100644 --- a/src/Mcp/Tests/Capability/Registry/LoaderTest.php +++ b/src/Mcp/Tests/Capability/Registry/LoaderTest.php @@ -119,6 +119,54 @@ class: \stdClass::class, $loader->load($registry); } + /** + * Regression test: input explicitly disabled (`input: false`) must not crash + * `buildSchema(null, ...)` — the tool should get an honest empty object input schema instead. + */ + public function testInputDisabledProducesEmptyObjectSchema(): void + { + $outputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($outputSchema['$schema']); + $outputSchema['type'] = 'object'; + $outputSchema['properties'] = ['id' => ['type' => 'integer']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->expects($this->once())->method('buildSchema')->willReturn($outputSchema); + + $mcpTool = new McpTool( + name: 'ping', + description: 'A tool with no input', + input: false, + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['ping' => $mcpTool]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with( + $this->callback(function (Tool $tool): bool { + $this->assertSame('ping', $tool->name); + $this->assertSame('object', $tool->inputSchema['type'] ?? null); + $this->assertInstanceOf(\stdClass::class, $tool->inputSchema['properties']); + $this->assertSame([], (array) $tool->inputSchema['properties']); + + return true; + }), + Loader::HANDLER, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + public function testResourceRegistration(): void { $mcpResource = new McpResource( diff --git a/src/Mcp/Tests/State/ToolProviderTest.php b/src/Mcp/Tests/State/ToolProviderTest.php new file mode 100644 index 0000000000..933a843611 --- /dev/null +++ b/src/Mcp/Tests/State/ToolProviderTest.php @@ -0,0 +1,72 @@ + + * + * 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\Mcp\Tests\State; + +use ApiPlatform\Mcp\State\ToolProvider; +use ApiPlatform\Metadata\Post; +use PHPUnit\Framework\TestCase; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ToolProviderTest extends TestCase +{ + public function testProvideReturnsNullWithoutMcpRequest(): void + { + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $operation = new Post(class: \stdClass::class); + + $provider = new ToolProvider($objectMapper); + $this->assertNull($provider->provide($operation, [], [])); + } + + public function testProvideMapsMcpDataToInputClass(): void + { + $mapped = new ToolProviderInputDummy(); + $operation = new Post(class: ToolProviderInputDummy::class); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($this->isInstanceOf(\stdClass::class), ToolProviderInputDummy::class) + ->willReturn($mapped); + + $provider = new ToolProvider($objectMapper); + $result = $provider->provide($operation, [], ['mcp_request' => true, 'mcp_data' => ['name' => 'foo']]); + + $this->assertSame($mapped, $result); + } + + /** + * Regression test: input explicitly disabled (`input: false`) must not crash calling + * `map($data, null)` — there's no target class to map into, so the raw data is returned as-is. + */ + public function testProvideReturnsRawDataWhenInputDisabled(): void + { + $operation = new Post(class: \stdClass::class, input: false); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + + $provider = new ToolProvider($objectMapper); + $result = $provider->provide($operation, [], ['mcp_request' => true, 'mcp_data' => ['name' => 'foo']]); + + $this->assertInstanceOf(\stdClass::class, $result); + $this->assertSame('foo', $result->name); + } +} + +class ToolProviderInputDummy +{ + public string $name = ''; +} diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 5d136533d8..27bcb68d1e 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -438,8 +438,16 @@ public function __construct( * @var string|bool|null */ protected $messenger = null, + /** + * @deprecated use inputClass instead + */ protected $input = null, + protected ?string $inputClass = null, + /** + * @deprecated use outputClass instead + */ protected $output = null, + protected ?string $outputClass = null, /** * Override the default order of items in your collection. Note that this is handled by our doctrine filters such as * the [OrderFilter](/docs/reference/Doctrine/Orm/Filter/OrderFilter). @@ -997,7 +1005,9 @@ class: $class, mercure: $mercure, messenger: $messenger, input: $input, + inputClass: $inputClass, output: $output, + outputClass: $outputClass, order: $order, fetchPartial: $fetchPartial, forceEager: $forceEager, diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 35e039c85a..4c62e7a0df 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -29,8 +29,10 @@ abstract class Metadata * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization * @param mixed|null $mercure * @param mixed|null $messenger - * @param mixed|null $input - * @param mixed|null $output + * @param mixed|null $input @deprecated use inputClass instead + * @param mixed|null $inputClass + * @param mixed|null $output @deprecated use outputClass instead + * @param mixed|null $outputClass * @param mixed|null $provider * @param mixed|null $processor * @param Parameters|array $parameters @@ -50,7 +52,9 @@ public function __construct( protected $mercure = null, protected $messenger = null, protected $input = null, + protected ?string $inputClass = null, protected $output = null, + protected ?string $outputClass = null, protected ?array $order = null, protected ?bool $fetchPartial = null, protected ?bool $forceEager = null, @@ -273,26 +277,139 @@ public function withMessenger(mixed $messenger): static return $self; } + /** + * @deprecated use getInputClass() instead + */ public function getInput(): mixed { + trigger_deprecation('api-platform/metadata', '4.3', 'The method "getInput()" is deprecated, use "getInputClass()" instead.'); + return $this->input; } + /** + * @deprecated use withInputClass() instead + */ public function withInput(mixed $input): static { + trigger_deprecation('api-platform/metadata', '4.3', 'The method "withInput()" is deprecated, use "withInputClass()" instead.'); + $self = clone $this; $self->input = $input; return $self; } + public function getInputClass(): ?string + { + if (null !== $this->inputClass) { + return $this->inputClass; + } + + if (false === $this->input) { + return null; + } + + if (\is_string($this->input)) { + return $this->input; + } + + if (\is_array($this->input) && \is_string($this->input['class'] ?? null)) { + return $this->input['class']; + } + + if (null === $this->input) { + return $this->getClass(); + } + + return null; + } + + public function hasExplicitInputClass(): bool + { + return null !== $this->input || null !== $this->inputClass; + } + + /** + * @param class-string|null $inputClass + * + * @return $this + */ + public function withInputClass(?string $inputClass): static + { + $self = clone $this; + $self->inputClass = $inputClass; + + // Null here means "resolved to disabled", not "unset" — mark it the same way an explicit + // `input: false` attribute would, so getInputClass() keeps returning null on this instance. + if (null === $inputClass) { + $self->input = false; + } + + return $self; + } + + public function getOutputClass(): ?string + { + if (null !== $this->outputClass) { + return $this->outputClass; + } + + if (false === $this->output) { + return null; + } + + if (\is_string($this->output)) { + return $this->output; + } + + if (\is_array($this->output) && \is_string($this->output['class'] ?? null)) { + return $this->output['class']; + } + + if (null === $this->output) { + return $this->getClass(); + } + + return null; + } + + public function hasExplicitOutputClass(): bool + { + return null !== $this->output || null !== $this->outputClass; + } + + public function withOutputClass(?string $outputClass): static + { + $self = clone $this; + $self->outputClass = $outputClass; + + // Null here means "resolved to disabled", not "unset" — mark it the same way an explicit + // `output: false` attribute would, so getOutputClass() keeps returning null on this instance. + if (null === $outputClass) { + $self->output = false; + } + + return $self; + } + + /** + * @deprecated use getOutputClass() instead + */ public function getOutput(): mixed { + trigger_deprecation('api-platform/metadata', '4.3', 'The method "getOutput()" is deprecated, use "getOutputClass()" instead.'); + return $this->output; } + /** + * @deprecated use withOutputClass() instead + */ public function withOutput(mixed $output): static { + trigger_deprecation('api-platform/metadata', '4.3', 'The method "withOutput()" is deprecated, use "withOutputClass()" instead.'); + $self = clone $this; $self->output = $output; diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 343915a673..1b08c2b507 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -729,8 +729,16 @@ public function __construct( * For more examples, read our guide on [validation](/guides/validation). */ protected ?array $validationContext = null, + /** + * @deprecated use inputClass instead + */ protected $input = null, + protected ?string $inputClass = null, + /** + * @deprecated use outputClass instead + */ protected $output = null, + protected ?string $outputClass = null, protected $mercure = null, /** * The `messenger` option dispatches the current resource through the Message Bus. @@ -832,7 +840,9 @@ class: $class, mercure: $mercure, messenger: $messenger, input: $input, + inputClass: $inputClass, output: $output, + outputClass: $outputClass, order: $order, fetchPartial: $fetchPartial, forceEager: $forceEager, diff --git a/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php index 00d2c6ea25..9e0b1c2b4b 100644 --- a/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/InputOutputResourceMetadataCollectionFactory.php @@ -37,8 +37,8 @@ public function create(string $resourceClass): ResourceMetadataCollection $resourceMetadataCollection = $this->decorated->create($resourceClass); foreach ($resourceMetadataCollection as $key => $resourceMetadata) { - $resourceMetadata = $resourceMetadata->withInput($this->transformInputOutput($resourceMetadata->getInput())); - $resourceMetadata = $resourceMetadata->withOutput($this->transformInputOutput($resourceMetadata->getOutput())); + $resourceMetadata = $resourceMetadata->withInputClass($resourceMetadata->getInputClass()); + $resourceMetadata = $resourceMetadata->withOutputClass($resourceMetadata->getOutputClass()); if ($resourceMetadata->getOperations()) { $resourceMetadata = $resourceMetadata->withOperations($this->getTransformedOperations($resourceMetadata->getOperations(), $resourceMetadata)); @@ -61,23 +61,25 @@ public function create(string $resourceClass): ResourceMetadataCollection private function getTransformedOperations(Operations|array $operations, ApiResource $resourceMetadata): Operations|array { foreach ($operations as $key => $operation) { - $operation = $operation->withInput(null !== $operation->getInput() ? $this->transformInputOutput($operation->getInput()) : $resourceMetadata->getInput()); - $operation = $operation->withOutput(null !== $operation->getOutput() ? $this->transformInputOutput($operation->getOutput()) : $resourceMetadata->getOutput()); - - if ( - $operation->getInput() - && \array_key_exists('class', $operation->getInput()) - && null === $operation->getInput()['class'] - ) { + $resolvedInputClass = $operation->hasExplicitInputClass() ? $operation->getInputClass() : $resourceMetadata->getInputClass(); + $operation = $operation->withInputClass($resolvedInputClass); + + $hasExplicit = $operation->hasExplicitOutputClass(); + $resolvedOutputClass = $hasExplicit ? $operation->getOutputClass() : $resourceMetadata->getOutputClass(); + $ref = new \ReflectionObject($operation); + $outputProp = $ref->getProperty('output'); $outputProp->setAccessible(true); + $outputClassProp = $ref->getProperty('outputClass'); $outputClassProp->setAccessible(true); + error_log("DEBUG [$key] hasExplicit=$hasExplicit raw_output=" . var_export($outputProp->getValue($operation), true) . " outputClass=" . var_export($outputClassProp->getValue($operation), true) . " resolved=" . var_export($resolvedOutputClass, true)); + $operation = $operation->withOutputClass($resolvedOutputClass); + + if (null === $resolvedInputClass) { $operation = $operation->withDeserialize(null === $operation->canDeserialize() ? false : $operation->canDeserialize()); $operation = $operation->withValidate(null === $operation->canValidate() ? false : $operation->canValidate()); } if ( $operation instanceof HttpOperation - && $operation->getOutput() - && \array_key_exists('class', $operation->getOutput()) - && null === $operation->getOutput()['class'] + && null === $resolvedOutputClass && null === $operation->getStatus() ) { $operation = $operation->withStatus(204); @@ -88,25 +90,4 @@ private function getTransformedOperations(Operations|array $operations, ApiResou return $operations; } - - private function transformInputOutput(mixed $attribute): ?array - { - if (false === $attribute) { - return ['class' => null]; - } - - if (!$attribute) { - return null; - } - - if (\is_string($attribute)) { - $attribute = ['class' => $attribute]; - } - - if (!isset($attribute['name']) && isset($attribute['class'])) { - $attribute['name'] = (new \ReflectionClass($attribute['class']))->getShortName(); - } - - return $attribute; - } } diff --git a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php index 37204def6a..4709b6c6b4 100644 --- a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php @@ -57,12 +57,17 @@ public function create(string $resourceClass): ResourceMetadataCollection $entityClass = $options->getModelClass(); } - $class = $operation->getInput()['class'] ?? $operation->getClass(); - $outputClass = $operation->getOutput()['class'] ?? null; + $class = $operation->getInputClass(); + $outputClass = $operation->getOutputClass(); $entityMap = null; + // Only guard output: skip when outputClass equals the resource class (fallback from + // getOutputClass() with no explicit output), to avoid triggering mapping for resources + // that declare #[Map] for entity→resource but have no separate output DTO. + $effectiveOutputClass = ($outputClass !== null && $outputClass !== $operation->getClass()) ? $outputClass : null; + // Look for Mapping metadata - if ($this->canBeMapped($class) || ($outputClass && $this->canBeMapped($outputClass)) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { + if ($this->canBeMapped($class) || ($effectiveOutputClass && $this->canBeMapped($effectiveOutputClass)) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { $found = true; if ($entityMap) { foreach ($entityMap as $mapping) { @@ -89,8 +94,12 @@ public function create(string $resourceClass): ResourceMetadataCollection /** * @return bool|list */ - private function canBeMapped(string $class): bool|array + private function canBeMapped(?string $class): bool|array { + if (!$class) { + return false; + } + try { $r = new \ReflectionClass($class); if (!$r->isInstantiable() || !($mapping = $this->objectMapperMetadata->create($r->newInstanceWithoutConstructor(), null, ['_api_check_can_be_mapped' => true]))) { diff --git a/src/Metadata/Tests/Resource/Factory/InputOutputResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/InputOutputResourceMetadataCollectionFactoryTest.php index 4f433e4d0b..e2ed11b99a 100644 --- a/src/Metadata/Tests/Resource/Factory/InputOutputResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/InputOutputResourceMetadataCollectionFactoryTest.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Metadata\Tests\Resource\Factory; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\InputOutputResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -27,7 +29,7 @@ class InputOutputResourceMetadataCollectionFactoryTest extends TestCase use ProphecyTrait; #[DataProvider('getAttributes')] - public function testInputOutputMetadata(mixed $input, ?array $expected): void + public function testInputOutputMetadata(mixed $input, ?string $expected): void { $resourceCollection = new ResourceMetadataCollection('Foo', [new ApiResource(input: $input)]); $decoratedProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); @@ -35,20 +37,79 @@ public function testInputOutputMetadata(mixed $input, ?array $expected): void $decorated = $decoratedProphecy->reveal(); $factory = new InputOutputResourceMetadataCollectionFactory($decorated); - $this->assertSame($expected, $factory->create('Foo')[0]->getInput()); + $this->assertSame($expected, $factory->create('Foo')[0]->getInputClass()); } public static function getAttributes(): array { return [ - // no input class defined + // empty array — no class to resolve [[], null], - // input is a string - [DummyEntity::class, ['class' => DummyEntity::class, 'name' => 'DummyEntity']], - // input is false - [false, ['class' => null]], - // input is an array - [['class' => DummyEntity::class, 'type' => 'Foo'], ['class' => DummyEntity::class, 'type' => 'Foo', 'name' => 'DummyEntity']], + // input is a string class name + [DummyEntity::class, DummyEntity::class], + // input: false disables deserialization, resolves to null + [false, null], + // input is an array with a class key + [['class' => DummyEntity::class, 'type' => 'Foo'], DummyEntity::class], + ]; + } + + /** + * Regression test: a resource-level `output: false` (or `input: false`) must still resolve to + * null on an operation that doesn't redeclare output/input itself, instead of silently falling + * back to the resource class (see Metadata::withOutputClass()/withInputClass()). + */ + #[DataProvider('getDisabledAttribute')] + public function testResourceLevelDisabledStatePropagatesToNonOverridingOperation(string $attribute, string $getter, string $hasExplicitMethod): void + { + $resourceCollection = new ResourceMetadataCollection('Foo', [ + (new ApiResource(...[$attribute => false]))->withOperations(new Operations(['get' => new Get()])), + ]); + $decoratedProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $decoratedProphecy->create('Foo')->willReturn($resourceCollection)->shouldBeCalled(); + $decorated = $decoratedProphecy->reveal(); + + $factory = new InputOutputResourceMetadataCollectionFactory($decorated); + $operation = $factory->create('Foo')[0]->getOperations()->getIterator()->current(); + + $this->assertNull($operation->$getter()); + $this->assertTrue($operation->$hasExplicitMethod()); + } + + public static function getDisabledAttribute(): array + { + return [ + ['output', 'getOutputClass', 'hasExplicitOutputClass'], + ['input', 'getInputClass', 'hasExplicitInputClass'], + ]; + } + + /** + * An operation can still re-enable its own output/input despite a resource-level disable. + */ + #[DataProvider('getReEnablingAttribute')] + public function testOperationCanReEnableDespiteResourceLevelDisable(string $attribute, string $getter): void + { + $resourceCollection = new ResourceMetadataCollection('Foo', [ + (new ApiResource(...[$attribute => false]))->withOperations(new Operations([ + 'get' => new Get(...[$attribute => DummyEntity::class]), + ])), + ]); + $decoratedProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $decoratedProphecy->create('Foo')->willReturn($resourceCollection)->shouldBeCalled(); + $decorated = $decoratedProphecy->reveal(); + + $factory = new InputOutputResourceMetadataCollectionFactory($decorated); + $operation = $factory->create('Foo')[0]->getOperations()->getIterator()->current(); + + $this->assertSame(DummyEntity::class, $operation->$getter()); + } + + public static function getReEnablingAttribute(): array + { + return [ + ['output', 'getOutputClass'], + ['input', 'getInputClass'], ]; } } diff --git a/src/Metadata/Tests/Resource/Factory/ObjectMapperMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/ObjectMapperMetadataCollectionFactoryTest.php new file mode 100644 index 0000000000..e710507ad9 --- /dev/null +++ b/src/Metadata/Tests/Resource/Factory/ObjectMapperMetadataCollectionFactoryTest.php @@ -0,0 +1,112 @@ + + * + * 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\Metadata\Tests\Resource\Factory; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\Factory\ObjectMapperMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; + +class ObjectMapperMetadataCollectionFactoryTest extends TestCase +{ + use ProphecyTrait; + + public function testSetsMapTrueWhenResourceClassHasMapAttribute(): void + { + $operation = $this->createFactory( + new Get(class: ObjectMapperMetaDummyResource::class), + ); + + $this->assertTrue($operation->canMap()); + } + + public function testDoesNotSetMapWhenNoMappingMetadata(): void + { + $operation = $this->createFactory( + new Get(class: ObjectMapperMetaDummyTarget::class), + ); + + $this->assertNull($operation->canMap()); + } + + /** + * Regression-adjacent test: input explicitly disabled (`input: false`) must NOT fall back to + * checking the resource class for #[Map] metadata — getInputClass() correctly returns null for + * "no input", and canBeMapped(null) correctly returns false, so canMap() stays unset here. + */ + public function testDoesNotFallBackToResourceClassWhenInputDisabled(): void + { + $operation = $this->createFactory( + new Get(class: ObjectMapperMetaDummyResource::class, input: false), + ); + + $this->assertNull($operation->canMap()); + } + + public function testSetsMapTrueWhenEntityClassFromStateOptionsMatches(): void + { + $operation = $this->createFactory( + new Get( + class: ObjectMapperMetaDummyResource::class, + input: false, + stateOptions: new Options(entityClass: ObjectMapperMetaDummyEntity::class), + ), + ); + + $this->assertTrue($operation->canMap()); + } + + public function testDoesNotOverwriteAlreadySetMap(): void + { + $operation = $this->createFactory( + new Get(class: ObjectMapperMetaDummyTarget::class, map: false), + ); + + $this->assertFalse($operation->canMap()); + } + + private function createFactory(Get $operation): Get + { + $resourceCollection = new ResourceMetadataCollection('Foo', [ + (new ApiResource())->withOperations(new Operations(['get' => $operation])), + ]); + $decoratedProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $decoratedProphecy->create('Foo')->willReturn($resourceCollection)->shouldBeCalled(); + + $factory = new ObjectMapperMetadataCollectionFactory($decoratedProphecy->reveal(), new ReflectionObjectMapperMetadataFactory()); + + return $factory->create('Foo')[0]->getOperations()->getIterator()->current(); + } +} + +#[Map(target: ObjectMapperMetaDummyTarget::class)] +class ObjectMapperMetaDummyResource +{ +} + +class ObjectMapperMetaDummyTarget +{ +} + +#[Map(target: ObjectMapperMetaDummyResource::class)] +class ObjectMapperMetaDummyEntity +{ +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 6273ea8ba5..755b5e46c6 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -473,7 +473,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ( \in_array($method, ['PATCH', 'PUT', 'POST'], true) - && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) + && $operation->getInputClass() ) { $content = $openapiOperation->getRequestBody()?->getContent(); if (null === $content) { @@ -521,7 +521,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection */ private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Operation $openapiOperation, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null, bool $isErrorResponse = false): Operation { - $noOutput = !$isErrorResponse && \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; + $noOutput = !$isErrorResponse && null === $operation?->getOutputClass(); $response = $existingResponses[$status] ?? new Response($description); if (null === $response->getDescription()) { diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 89463d4fc6..055b9ce3cd 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -1298,9 +1298,7 @@ public function testInvoke(): void [ '201' => new Response( 'Dummy resource created', - new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject([])), - ]), + null, null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), diff --git a/src/Serializer/InputOutputMetadataTrait.php b/src/Serializer/InputOutputMetadataTrait.php index 03b604f97f..16d16f9b67 100644 --- a/src/Serializer/InputOutputMetadataTrait.php +++ b/src/Serializer/InputOutputMetadataTrait.php @@ -21,6 +21,10 @@ trait InputOutputMetadataTrait protected function getInputClass(array $context = []): ?string { + if (\is_string($context['input'] ?? null)) { + return $context['input']; + } + if (!$this->resourceMetadataCollectionFactory) { return $context['input']['class'] ?? null; } @@ -34,6 +38,10 @@ protected function getInputClass(array $context = []): ?string protected function getOutputClass(array $context = []): ?string { + if (\is_string($context['output'] ?? null)) { + return $context['output']; + } + if (!$this->resourceMetadataCollectionFactory) { return $context['output']['class'] ?? null; } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 63ee797f42..215f81aa02 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -64,8 +64,8 @@ public function createFromRequest(Request $request, bool $normalization, ?array $context['iri_only'] ??= false; $context['request_uri'] = $request->getRequestUri(); $context['uri'] = $request->getUri(); - $context['input'] = $operation->getInput(); - $context['output'] = $operation->getOutput(); + $context['input'] = $operation->getInputClass() === $operation->getClass() ? null : ['class' => $operation->getInputClass()]; + $context['output'] = $operation->getOutputClass() === $operation->getClass() ? null : ['class' => $operation->getOutputClass()]; // Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) { diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index be1a3d3d20..46b1f53b3c 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -65,6 +65,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !($request = $context['request'] ?? null) || !$operation->getJsonStream() || 'json' !== $request->getRequestFormat() + || null === ($outputClass = $operation->getOutputClass()) ) { return $this->processor?->process($data, $operation, $uriVariables, $context); } @@ -82,13 +83,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables = if ($operation instanceof CollectionOperationInterface) { $data = $this->jsonStreamer->write( $data, - Type::list(Type::object($operation->getClass())), + Type::list(Type::object($outputClass)), ['data' => $data, 'operation' => $operation], ); } else { $data = $this->jsonStreamer->write( $data, - Type::object($operation->getClass()), + Type::object($outputClass), ['data' => $data, 'operation' => $operation], ); } diff --git a/src/Serializer/State/JsonStreamerProvider.php b/src/Serializer/State/JsonStreamerProvider.php index 26ac7fc923..79602d2318 100644 --- a/src/Serializer/State/JsonStreamerProvider.php +++ b/src/Serializer/State/JsonStreamerProvider.php @@ -35,11 +35,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data'); - if (!$operation->canDeserialize() || 'json' !== $request->attributes->get('input_format')) { + if (!$operation->canDeserialize() || 'json' !== $request->attributes->get('input_format') || null === ($inputClass = $operation->getInputClass())) { return $data; } - $data = $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass())); + $data = $this->jsonStreamReader->read($request->getContent(true), Type::object($inputClass)); $context['request']->attributes->set('deserialized', true); if (\PHP_VERSION_ID > 80400) { diff --git a/src/Serializer/Tests/State/JsonStreamerProviderTest.php b/src/Serializer/Tests/State/JsonStreamerProviderTest.php new file mode 100644 index 0000000000..52de60d0b5 --- /dev/null +++ b/src/Serializer/Tests/State/JsonStreamerProviderTest.php @@ -0,0 +1,92 @@ + + * + * 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\Serializer\Tests\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Serializer\State\JsonStreamerProvider; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\TypeInfo\Type; + +class JsonStreamerProviderTest extends TestCase +{ + public function testProvideReadsJsonContentIntoInputClass(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true); + $request = new Request(content: '{}'); + $request->attributes->set('input_format', 'json'); + + $jsonStreamReader = $this->createMock(StreamReaderInterface::class); + $jsonStreamReader->expects($this->once()) + ->method('read') + ->with($this->isType('resource'), $this->equalTo(Type::object(\stdClass::class))) + ->willReturn($data); + + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new JsonStreamerProvider($decorated, $jsonStreamReader); + $result = $provider->provide($operation, [], ['request' => $request]); + + $this->assertSame($data, $result); + $this->assertTrue($request->attributes->get('deserialized')); + } + + public function testProvideBypassesWhenNotJsonFormat(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true); + $request = new Request(); + $request->attributes->set('input_format', 'jsonld'); + $request->attributes->set('data', $data); + + $jsonStreamReader = $this->createMock(StreamReaderInterface::class); + $jsonStreamReader->expects($this->never())->method('read'); + + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + + $provider = new JsonStreamerProvider($decorated, $jsonStreamReader); + $result = $provider->provide($operation, [], ['request' => $request]); + + $this->assertSame($data, $result); + } + + /** + * Regression test: input explicitly disabled (`input: false`) combined with an explicit + * `deserialize: true` (a contradictory config) must not crash `Type::object(null)` — it should + * bypass instead. + */ + public function testProvideBypassesWhenInputDisabledDespiteDeserializeTrue(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class, jsonStream: true, deserialize: true, input: false); + $request = new Request(content: '{}'); + $request->attributes->set('input_format', 'json'); + + $jsonStreamReader = $this->createMock(StreamReaderInterface::class); + $jsonStreamReader->expects($this->never())->method('read'); + + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + + $provider = new JsonStreamerProvider($decorated, $jsonStreamReader); + $result = $provider->provide($operation, [], ['request' => $request]); + + $this->assertSame($data, $result); + } +} diff --git a/src/State/Processor/ObjectMapperInputProcessor.php b/src/State/Processor/ObjectMapperInputProcessor.php index 845be27e09..b4e8b976ea 100644 --- a/src/State/Processor/ObjectMapperInputProcessor.php +++ b/src/State/Processor/ObjectMapperInputProcessor.php @@ -39,7 +39,7 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $class = $operation->getInput()['class'] ?? $operation->getClass(); + $class = $operation->getInputClass(); if ( $data instanceof Response diff --git a/src/State/Processor/ObjectMapperOutputProcessor.php b/src/State/Processor/ObjectMapperOutputProcessor.php index 91a4fa169e..121d0b27df 100644 --- a/src/State/Processor/ObjectMapperOutputProcessor.php +++ b/src/State/Processor/ObjectMapperOutputProcessor.php @@ -48,7 +48,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $request = $context['request'] ?? null; $request?->attributes->set('persisted_data', $data); - $dto = $this->objectMapper->map($data, $operation->getClass()); + $dto = $this->objectMapper->map($data, $operation->getOutputClass()); return $this->decorated ? $this->decorated->process($dto, $operation, $uriVariables, $context) : $dto; } diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index f7bb34a367..66b52ddee1 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -40,19 +40,19 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $class = $operation->getInput()['class'] ?? $operation->getClass(); + $class = $operation->getInputClass(); if ( $data instanceof Response || !$this->objectMapper || !$operation->canWrite() || null === $data + || null === $class || !is_a($data, $class, true) || !$operation->canMap() ) { return $this->decorated->process($data, $operation, $uriVariables, $context); } - $request = $context['request'] ?? null; // maps the Resource to an Entity @@ -81,7 +81,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->objectMapper->map( // persist the entity $persisted, - $operation->getClass() + $operation->getOutputClass() ); } } diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php index 42e8125228..3ce7fe199f 100644 --- a/src/State/Provider/ContentNegotiationProvider.php +++ b/src/State/Provider/ContentNegotiationProvider.php @@ -97,11 +97,7 @@ private function flattenMimeTypes(array $formats): array */ private function getInputFormat(HttpOperation $operation, Request $request): ?string { - if ( - false === ($input = $operation->getInput()) - || (\is_array($input) && null === $input['class']) - || false === $operation->canDeserialize() - ) { + if (false === $operation->canDeserialize()) { return null; } diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index 9986f43954..2fadf0c3a3 100644 --- a/src/State/Provider/ObjectMapperProvider.php +++ b/src/State/Provider/ObjectMapperProvider.php @@ -41,9 +41,10 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $data = $this->decorated->provide($operation, $uriVariables, $context); - $class = $operation->getOutput()['class'] ?? $operation->getClass(); + $class = $operation->getOutputClass(); - if (!$this->objectMapper || !$operation->canMap()) { + // Output explicitly disabled: there's no resource shape to map into, so leave the data as-is. + if (!$this->objectMapper || !$operation->canMap() || null === $class) { return $data; } diff --git a/src/State/Tests/Processor/ObjectMapperProcessorTest.php b/src/State/Tests/Processor/ObjectMapperProcessorTest.php new file mode 100644 index 0000000000..2953d64746 --- /dev/null +++ b/src/State/Tests/Processor/ObjectMapperProcessorTest.php @@ -0,0 +1,96 @@ + + * + * 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\State\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\State\Processor\ObjectMapperProcessor; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ObjectMapperProcessorTest extends TestCase +{ + public function testProcessBypassesWhenNoObjectMapper(): void + { + $data = new \stdClass(); + $operation = new Post(class: \stdClass::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperProcessor(null, $decorated); + $this->assertSame($data, @$processor->process($data, $operation)); + } + + public function testProcessBypassesOnNonWriteOperation(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $this->assertSame($data, @$processor->process($data, $operation)); + } + + /** + * Regression test: input explicitly disabled (`input: false`) must not crash + * `is_a($data, $class, true)` with a null $class — it should bypass to the decorated processor instead. + */ + public function testProcessBypassesWhenInputDisabled(): void + { + $data = new \stdClass(); + $operation = new Post(class: \stdClass::class, input: false, map: true, write: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $this->assertSame($data, @$processor->process($data, $operation)); + } + + public function testProcessMapsInputToEntity(): void + { + $dto = new \stdClass(); + $entity = new \stdClass(); + $persisted = new \stdClass(); + $final = new \stdClass(); + $operation = new Post(class: \stdClass::class, map: true, write: true); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->exactly(2)) + ->method('map') + ->willReturnOnConsecutiveCalls($entity, $final); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($entity, $operation, [], $this->anything()) + ->willReturn($persisted); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $this->assertSame($final, @$processor->process($dto, $operation)); + } +} \ No newline at end of file diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index a608706fa0..d560b77ca3 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -53,10 +53,8 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c { $status = $this->getStatus($request, $operation, $context); $method = $request->getMethod(); - $output = $operation->getOutput(); - $outputMetadata = $output ?? ['class' => $operation->getClass()]; - $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; - $outputExplicitlyDisabled = \is_array($output) && \array_key_exists('class', $output) && null === $output['class']; + $hasOutput = null !== $operation->getOutputClass(); + $outputExplicitlyDisabled = !$hasOutput && $operation->hasExplicitOutputClass(); // RFC 7230 §3.3.2 / §3.3.3: 204, 205 and 304 responses MUST NOT include a payload body, // and a sender MUST NOT generate a Content-Type field for a message without a body. $isBodylessStatus = \in_array($status, [Response::HTTP_NO_CONTENT, Response::HTTP_RESET_CONTENT, Response::HTTP_NOT_MODIFIED], true); @@ -90,7 +88,7 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c } $originalData = $context['original_data'] ?? null; - $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); + $hasData = $hasOutput && $this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData)); if ($hasData) { $isAlternateResourceMetadata = $operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false; diff --git a/src/State/Util/HttpResponseStatusTrait.php b/src/State/Util/HttpResponseStatusTrait.php index 8674554038..68915cae20 100644 --- a/src/State/Util/HttpResponseStatusTrait.php +++ b/src/State/Util/HttpResponseStatusTrait.php @@ -44,8 +44,7 @@ private function getStatus(Request $request, HttpOperation $operation, array $co $status = $operation->getStatus(); $method = $request->getMethod(); - $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; - $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; + $hasOutput = null !== $operation->getOutputClass(); $originalData = $context['original_data'] ?? null; $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); diff --git a/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php b/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php index 7aa5703655..6b31e860d6 100644 --- a/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php +++ b/tests/Fixtures/TestBundle/State/DummyCollectionDtoProvider.php @@ -23,7 +23,7 @@ class DummyCollectionDtoProvider implements ProviderInterface { public function provide(Operation $operation, array $uriVariables = [], array $context = []): array { - $class = $operation->getOutput()['class']; + $class = $operation->getOutputClass(); switch ($class) { case DummyCollectionDtoOutput::class: case DummyFooCollectionDto::class: diff --git a/tests/State/Provider/ObjectMapperProviderTest.php b/tests/State/Provider/ObjectMapperProviderTest.php index 672134c7d3..c02385eadb 100644 --- a/tests/State/Provider/ObjectMapperProviderTest.php +++ b/tests/State/Provider/ObjectMapperProviderTest.php @@ -64,6 +64,38 @@ public function testProvideBypassesWhenDataIsNull(): void $this->assertNull($result); } + /** + * Regression test: output explicitly disabled (`output: false`) must not crash constructing + * `MappedObjectPaginator`/calling `map()` with a null resource class — it should bypass. + */ + public function testProvideBypassesWhenOutputDisabled(): void + { + $data = new SourceEntity(); + $operation = new Get(class: TargetResource::class, output: false, map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertSame($data, $result); + } + + public function testProvideBypassesPaginatorWhenOutputDisabled(): void + { + $paginator = new ArrayPaginator([new SourceEntity(), new SourceEntity()], 0, 10); + $operation = new Get(class: TargetResource::class, output: false, map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($paginator); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertSame($paginator, $result); + } + public function testProvideMapsObject(): void { $sourceEntity = new SourceEntity();