Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/GraphQl/Serializer/SerializerContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions src/GraphQl/State/Provider/DenormalizeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion src/GraphQl/State/Provider/ResolverProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 4 additions & 6 deletions src/GraphQl/Type/TypeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}

Expand Down
13 changes: 4 additions & 9 deletions src/Hydra/Serializer/DocumentationNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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 += [
Expand Down
5 changes: 3 additions & 2 deletions src/Hydra/State/JsonStreamerProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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],
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/Hydra/State/JsonStreamerProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
92 changes: 92 additions & 0 deletions src/Hydra/Tests/State/JsonStreamerProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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);
}
}
11 changes: 8 additions & 3 deletions src/JsonSchema/ResourceMetadataTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/Mcp/Capability/Registry/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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(),
Expand Down
7 changes: 6 additions & 1 deletion src/Mcp/State/ToolProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
48 changes: 48 additions & 0 deletions src/Mcp/Tests/Capability/Registry/LoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading