diff --git a/Config/CustomScalarTypeDefinition.php b/Config/CustomScalarTypeDefinition.php index 841595bd8..094f4e8f3 100644 --- a/Config/CustomScalarTypeDefinition.php +++ b/Config/CustomScalarTypeDefinition.php @@ -15,9 +15,10 @@ public function getDefinition() ->children() ->append($this->nameSection()) ->append($this->descriptionSection()) - ->variableNode('serialize')->isRequired()->end() - ->variableNode('parseValue')->isRequired()->end() - ->variableNode('parseLiteral')->isRequired()->end() + ->variableNode('scalarType')->end() + ->variableNode('serialize')->end() + ->variableNode('parseValue')->end() + ->variableNode('parseLiteral')->end() ->end(); return $node; diff --git a/Definition/Type/CustomScalarType.php b/Definition/Type/CustomScalarType.php new file mode 100644 index 000000000..fc72b09cf --- /dev/null +++ b/Definition/Type/CustomScalarType.php @@ -0,0 +1,88 @@ +config['scalarType'] = isset($this->config['scalarType']) ? $this->config['scalarType'] : null; + $this->initOptionalFunctions(); + } + + /** + * {@inheritdoc} + */ + public function serialize($value) + { + return $this->call('serialize', $value); + } + + /** + * {@inheritdoc} + */ + public function parseValue($value) + { + return $this->call('parseValue', $value); + } + + /** + * {@inheritdoc} + */ + public function parseLiteral(/* GraphQL\Language\AST\ValueNode */ $valueNode) + { + return $this->call('parseLiteral', $valueNode); + } + + private function call($type, $value) + { + if (isset($this->config['scalarType'])) { + $scalarType = $this->config['scalarType']; + $scalarType = is_callable($scalarType) ? $scalarType() : $scalarType; + + return call_user_func([$scalarType, $type], $value); + } else { + return parent::$type($value); + } + } + + private function initOptionalFunctions() + { + foreach (['parseLiteral', 'parseValue'] as $field) { + if (!isset($this->config[$field])) { + $this->config[$field] = static function () { + return null; + }; + } + } + } + + public function assertValid() + { + if (isset($this->config['scalarType'])) { + $scalarType = $this->config['scalarType']; + if (is_callable($scalarType)) { + $scalarType = $scalarType(); + } + + Utils::invariant( + $scalarType instanceof ScalarType, + sprintf( + '%s must provide a valid "scalarType" instance of %s but got: %s', + $this->name, + ScalarType::class, + Utils::printSafe($scalarType) + ) + ); + } else { + parent::assertValid(); + } + } +} diff --git a/Definition/Type/SchemaDecorator.php b/Definition/Type/SchemaDecorator.php index f273b6e07..b8fa4ee99 100644 --- a/Definition/Type/SchemaDecorator.php +++ b/Definition/Type/SchemaDecorator.php @@ -2,7 +2,6 @@ namespace Overblog\GraphQLBundle\Definition\Type; -use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; @@ -84,7 +83,12 @@ private function decorateInterfaceOrUnionType($type, ResolverMapInterface $resol private function decorateCustomScalarType(CustomScalarType $type, ResolverMapInterface $resolverMap) { - static $allowedFields = [ResolverMapInterface::SERIALIZE, ResolverMapInterface::PARSE_VALUE, ResolverMapInterface::PARSE_LITERAL]; + static $allowedFields = [ + ResolverMapInterface::SCALAR_TYPE, + ResolverMapInterface::SERIALIZE, + ResolverMapInterface::PARSE_VALUE, + ResolverMapInterface::PARSE_LITERAL, + ]; foreach ($allowedFields as $fieldName) { $this->configTypeMapping($type, $resolverMap, $fieldName); diff --git a/Generator/TypeGenerator.php b/Generator/TypeGenerator.php index 1b011bb1a..b44ddd7a2 100644 --- a/Generator/TypeGenerator.php +++ b/Generator/TypeGenerator.php @@ -5,6 +5,7 @@ use Composer\Autoload\ClassLoader; use Overblog\GraphQLBundle\Config\Processor; use Overblog\GraphQLBundle\Definition\Argument; +use Overblog\GraphQLBundle\Definition\Type\CustomScalarType; use Overblog\GraphQLGenerator\Generator\TypeGenerator as BaseTypeGenerator; use Symfony\Component\Filesystem\Filesystem; @@ -141,6 +142,25 @@ function ($childrenComplexity, $args = []) { return $code; } + /** + * @param array $value + * + * @return string + */ + protected function generateScalarType(array $value) + { + return $this->callableCallbackFromArrayValue($value, 'scalarType'); + } + + protected function generateParentClassName(array $config) + { + if ('custom-scalar' === $config['type']) { + return $this->shortenClassName(CustomScalarType::class); + } else { + return parent::generateParentClassName($config); + } + } + public function compile($mode) { $cacheDir = $this->getCacheDir(); diff --git a/README.md b/README.md index 22de11446..3e8d694d6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,10 @@ OverblogGraphQLBundle This Symfony bundle provides integration of [GraphQL](https://facebook.github.io/graphql/) using [webonyx/graphql-php](https://github.com/webonyx/graphql-php) and [GraphQL Relay](https://facebook.github.io/relay/docs/graphql-relay-specification.html). -It also supports batching using libs like [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer) or [Apollo GraphQL](http://dev.apollodata.com/core/network.html#query-batching). +It also supports: +* batching with [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer) +* batching with [Apollo GraphQL](http://dev.apollodata.com/core/network.html#query-batching). +* upload and batching upload with [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) [![Build Status](https://travis-ci.org/overblog/GraphQLBundle.svg?branch=master)](https://travis-ci.org/overblog/GraphQLBundle) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/overblog/GraphQLBundle/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/overblog/GraphQLBundle/?branch=master) diff --git a/Request/BatchParser.php b/Request/BatchParser.php index 28aa7fe04..45bd61b41 100644 --- a/Request/BatchParser.php +++ b/Request/BatchParser.php @@ -7,6 +7,8 @@ class BatchParser implements ParserInterface { + use UploadParserTrait; + const PARAM_ID = 'id'; private static $queriesDefaultValue = [ @@ -49,17 +51,29 @@ public function parse(Request $request) */ private function getParsedBody(Request $request) { - $type = explode(';', $request->headers->get('content-type'))[0]; + $contentType = explode(';', $request->headers->get('content-type'))[0]; // JSON object - if ($type !== static::CONTENT_TYPE_JSON) { - throw new BadRequestHttpException(sprintf('Only request with content type "%s" is accepted.', static::CONTENT_TYPE_JSON)); - } + switch ($contentType) { + case static::CONTENT_TYPE_JSON: + $parsedBody = json_decode($request->getContent(), true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new BadRequestHttpException('POST body sent invalid JSON'); + } + break; - $parsedBody = json_decode($request->getContent(), true); + case static::CONTENT_TYPE_FORM_DATA: + $parsedBody = $this->treatUploadFiles($request->request->all(), $request->files->all()); + break; - if (JSON_ERROR_NONE !== json_last_error()) { - throw new BadRequestHttpException('POST body sent invalid JSON'); + default: + throw new BadRequestHttpException(sprintf( + 'Batching parser only accepts "%s" or "%s" content-type but got %s.', + static::CONTENT_TYPE_JSON, + static::CONTENT_TYPE_FORM_DATA, + json_encode($contentType) + )); } return $parsedBody; diff --git a/Request/Parser.php b/Request/Parser.php index 1a0e2a369..7a2673970 100644 --- a/Request/Parser.php +++ b/Request/Parser.php @@ -7,6 +7,8 @@ class Parser implements ParserInterface { + use UploadParserTrait; + /** * @param Request $request * @@ -55,7 +57,7 @@ private function getParsedBody(Request $request) // URL-encoded query-string case static::CONTENT_TYPE_FORM: case static::CONTENT_TYPE_FORM_DATA: - $parsedBody = $request->request->all(); + $parsedBody = $this->treatUploadFiles($request->request->all(), $request->files->all()); break; default: diff --git a/Request/UploadParserTrait.php b/Request/UploadParserTrait.php new file mode 100644 index 000000000..3a24fb7e4 --- /dev/null +++ b/Request/UploadParserTrait.php @@ -0,0 +1,64 @@ +enableExceptionOnInvalidIndex() + ->getPropertyAccessor(); + + foreach ($map as $fileName => $locations) { + foreach ($locations as $location) { + $fileKey = sprintf('[%s]', $fileName); + if (!$accessor->isReadable($files, $fileKey)) { + throw new BadRequestHttpException(sprintf('File %s is missing in the request.', json_encode($fileName))); + } + $file = $accessor->getValue($files, $fileKey); + $locationKey = $this->locationToPropertyAccessPath($location); + if (!$accessor->isReadable($operations, $locationKey)) { + throw new BadRequestHttpException(sprintf('Map entry %s could not be localized in operations.', json_encode($location))); + } + $accessor->setValue($operations, $locationKey, $file); + } + } + + return $operations; + } + + protected function locationToPropertyAccessPath($location) + { + return array_reduce( + explode('.', $location), + function ($carry, $item) { + return sprintf('%s[%s]', $carry, $item); + } + ); + } + + protected function isUploadPayload(array $payload) + { + return isset($payload['operations']) && isset($payload['map']) && is_array($payload['operations']) && is_array($payload['map']); + } + + protected function treatUploadFiles(array $parsedBody, array $files) + { + if ($this->isUploadPayload($parsedBody)) { + return $this->mappingUploadFiles($parsedBody['operations'], $parsedBody['map'], $files); + } else { + return $parsedBody; + } + } +} diff --git a/Resolver/ResolverMapInterface.php b/Resolver/ResolverMapInterface.php index 303fc967d..8238179fd 100644 --- a/Resolver/ResolverMapInterface.php +++ b/Resolver/ResolverMapInterface.php @@ -10,6 +10,7 @@ interface ResolverMapInterface const RESOLVE_FIELD = '__resolveField'; const IS_TYPEOF = '__isTypeOf'; // custom scalar + const SCALAR_TYPE = '__scalarType'; const SERIALIZE = '__serialize'; const PARSE_VALUE = '__parseValue'; const PARSE_LITERAL = '__parseLiteral'; diff --git a/Resources/doc/definitions/resolver-map.md b/Resources/doc/definitions/resolver-map.md index 8a46964a7..6bb027285 100644 --- a/Resources/doc/definitions/resolver-map.md +++ b/Resources/doc/definitions/resolver-map.md @@ -39,9 +39,12 @@ and override `map` method and return an `array` or any `ArrayAccess` and `Traver - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::RESOLVE_FIELD` equivalent to `resolveField`. - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::IS_TYPE_OF` equivalent to `isTypeOf`. * [Custom scalar](type-system/scalars.md#custom-scalar) type - - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::SERIALIZE` equivalent to `serialize` - - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::PARSE_VALUE` equivalent to `parseValue` - - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::PARSE_LITERAL` equivalent to `parseLiteral` + - Direct usage: + - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::SERIALIZE` equivalent to `serialize` + - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::PARSE_VALUE` equivalent to `parseValue` + - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::PARSE_LITERAL` equivalent to `parseLiteral` + - Reusing an existing scalar type + - `Overblog\GraphQLBundle\Resolver\ResolverMapInterface::SCALAR_TYPE` equivalent to `scalarType` Usage ----- @@ -109,6 +112,8 @@ class MyResolverMap extends ResolverMap return str_replace(' Formatted Baz', '', $valueNode->value); }, ], + // or reuse an existing scalar (note: description and name will be override by decorator) + //'Baz' => [self::SCALAR_TYPE => function () { return new FooScalarType(); }], ]; } } diff --git a/Resources/doc/definitions/type-system/scalars.md b/Resources/doc/definitions/type-system/scalars.md index b9964dc06..d6f570210 100644 --- a/Resources/doc/definitions/type-system/scalars.md +++ b/Resources/doc/definitions/type-system/scalars.md @@ -63,3 +63,12 @@ class DateTimeType } } ``` + +If you prefer reusing a scalar type + +```yaml +MyEmail: + type: custom-scalar + config: + scalarType: '@=newObject("App\\Type\\EmailType")' +``` diff --git a/Resources/doc/definitions/upload-files.md b/Resources/doc/definitions/upload-files.md index e3fb41735..e3aca2a1f 100644 --- a/Resources/doc/definitions/upload-files.md +++ b/Resources/doc/definitions/upload-files.md @@ -1,5 +1,74 @@ ### Upload files +Using apollo-upload-client +-------------------------- + +The bundle comes of the box with a server compatible with +[apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client). + +1. define upload scalar type using yaml + + ```yaml + MyUpload: + type: custom-scalar + config: + scalarType: '@=newObject("Overblog\\GraphQLBundle\\Upload\\Type\\GraphQLUploadType")' + ``` + + or with GraphQL schema language + + ```graphql + scalar MyUpload + ``` + + ```php + [self::SCALAR_TYPE => function () { return new GraphQLUploadType(); }], + ]; + } + } + ``` + + You can name as you want just replace `MyUpload` in above examples. + +2. Use it in your Schema + Here an example: + + ```yaml + Mutation: + type: object + config: + fields: + singleUpload: + type: String! + resolve: '@=args["file"].getBasename()' + args: + file: MyUpload! + multipleUpload: + type: '[String!]' + resolve: '@=[args["files"][0].getBasename(), args["files"][1].getBasename()]' + args: + files: '[MyUpload!]!' + ``` + + **Notes:** + - Files args are of type `Symfony\Component\HttpFoundation\File\UploadedFile` + - Upload scalar type can be use only on inputs fields (args or InputObject) + +The classic way +--------------- + here an example of how uploading can be done using this bundle * First define schema diff --git a/Resources/skeleton/CustomScalarConfig.php.skeleton b/Resources/skeleton/CustomScalarConfig.php.skeleton new file mode 100644 index 000000000..3d1138ee7 --- /dev/null +++ b/Resources/skeleton/CustomScalarConfig.php.skeleton @@ -0,0 +1,8 @@ +[ +'name' => , +'description' => , +'scalarType' => , +'serialize' => , +'parseValue' => , +'parseLiteral' => , +] diff --git a/Tests/Definition/Type/CustomScalarTypeTest.php b/Tests/Definition/Type/CustomScalarTypeTest.php new file mode 100644 index 000000000..6d849192e --- /dev/null +++ b/Tests/Definition/Type/CustomScalarTypeTest.php @@ -0,0 +1,105 @@ +assertScalarTypeConfig(new YearScalarType()); + $this->assertScalarTypeConfig(function () { + return new YearScalarType(); + }); + } + + public function testWithoutScalarTypeConfig() + { + $genericFunc = function ($value) { + return $value; + }; + $type = new CustomScalarType([ + 'serialize' => $genericFunc, + 'parseValue' => $genericFunc, + 'parseLiteral' => $genericFunc, + ]); + + foreach (['serialize', 'parseValue', 'parseLiteral'] as $field) { + $value = new \stdClass(); + $this->assertSame($value, $type->$field($value)); + } + } + + /** + * @param mixed $scalarType + * @param string $got + * + * @dataProvider invalidScalarTypeProvider + */ + public function testAssertValidWithInvalidScalarType($scalarType, $got) + { + $this->expectException(InvariantViolation::class); + $name = uniqid('custom'); + $this->expectExceptionMessage(sprintf( + '%s must provide a valid "scalarType" instance of %s but got: %s', + $name, + ScalarType::class, + $got + )); + $type = new CustomScalarType(['name' => $name, 'scalarType' => $scalarType]); + $type->assertValid(); + } + + public function testAssertValidSerializeFunctionIsRequired() + { + $this->expectException(InvariantViolation::class); + $name = uniqid('custom'); + $this->expectExceptionMessage($name.' must provide "serialize" function. If this custom Scalar is also used as an input type, ensure "parseValue" and "parseLiteral" functions are also provided.'); + $type = new CustomScalarType(['name' => $name]); + $type->assertValid(); + } + + public function invalidScalarTypeProvider() + { + yield [false, 'false']; + yield [new \stdClass(), 'instance of stdClass']; + yield [ + function () { + return false; + }, + 'false', + ]; + yield [ + function () { + return new \stdClass(); + }, + 'instance of stdClass', + ]; + } + + private function assertScalarTypeConfig($scalarType) + { + $type = new CustomScalarType([ + 'scalarType' => $scalarType, + 'serialize' => function () { + return 'serialize'; + }, + 'parseValue' => function () { + return 'parseValue'; + }, + 'parseLiteral' => function () { + return 'parseLiteral'; + }, + ]); + + $this->assertSame('50 AC', $type->serialize(50)); + $this->assertSame(50, $type->parseValue('50 AC')); + $this->assertSame(50, $type->parseLiteral(new StringValueNode(['value' => '50 AC']))); + } +} diff --git a/Tests/Definition/Type/SchemaDecoratorTest.php b/Tests/Definition/Type/SchemaDecoratorTest.php index 2fd791ac1..77c24b406 100644 --- a/Tests/Definition/Type/SchemaDecoratorTest.php +++ b/Tests/Definition/Type/SchemaDecoratorTest.php @@ -2,7 +2,6 @@ namespace Overblog\GraphQLBundle\Tests\Definition\Type; -use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; @@ -11,6 +10,7 @@ use GraphQL\Type\Definition\UnionType; use GraphQL\Type\Schema; use Overblog\GraphQLBundle\Definition\Argument; +use Overblog\GraphQLBundle\Definition\Type\CustomScalarType; use Overblog\GraphQLBundle\Definition\Type\SchemaDecorator; use Overblog\GraphQLBundle\Resolver\ResolverMap; use Overblog\GraphQLBundle\Resolver\ResolverMapInterface; @@ -202,7 +202,7 @@ public function testCustomScalarTypeUnknownField() ], ], \InvalidArgumentException::class, - '"Foo".{"baz"} defined in resolverMap, but only "__serialize", "__parseValue", "__parseLiteral" is allowed.' + '"Foo".{"baz"} defined in resolverMap, but only "__scalarType", "__serialize", "__parseValue", "__parseLiteral" is allowed.' ); } @@ -266,6 +266,7 @@ function (ObjectType $type) { [ResolverMapInterface::SERIALIZE, new CustomScalarType(['name' => 'Custom', 'serialize' => null])], [ResolverMapInterface::PARSE_VALUE, new CustomScalarType(['name' => 'Custom', 'parseValue' => null])], [ResolverMapInterface::PARSE_LITERAL, new CustomScalarType(['name' => 'Custom', 'parseLiteral' => null])], + [ResolverMapInterface::SCALAR_TYPE, new CustomScalarType(['name' => 'Custom'])], ]; } diff --git a/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php b/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php index 6378c696c..c99e99193 100644 --- a/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php +++ b/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php @@ -2,12 +2,10 @@ namespace Overblog\GraphQLBundle\Tests\Functional\App\Resolver; -use GraphQL\Error\Error; -use GraphQL\Language\AST\StringValueNode; use GraphQL\Type\Definition\ResolveInfo; -use GraphQL\Utils; use Overblog\GraphQLBundle\Definition\Argument; use Overblog\GraphQLBundle\Resolver\ResolverMap; +use Overblog\GraphQLBundle\Tests\Functional\App\Type\YearScalarType; class SchemaLanguageQueryResolverMap extends ResolverMap { @@ -58,22 +56,8 @@ protected function map() ], // custom scalar 'Year' => [ - self::SERIALIZE => function ($value) { - return sprintf('%s AC', $value); - }, - self::PARSE_VALUE => function ($value) { - if (!is_string($value)) { - throw new Error(sprintf('Cannot represent following value as a valid year: %s.', Utils::printSafeJson($value))); - } - - return (int) str_replace(' AC', '', $value); - }, - self::PARSE_LITERAL => function ($valueNode) { - if (!$valueNode instanceof StringValueNode) { - throw new Error('Query error: Can only parse strings got: '.$valueNode->kind, [$valueNode]); - } - - return (int) str_replace(' AC', '', $valueNode->value); + self::SCALAR_TYPE => function () { + return new YearScalarType(); }, ], ]; diff --git a/Tests/Functional/App/Type/YearScalarType.php b/Tests/Functional/App/Type/YearScalarType.php new file mode 100644 index 000000000..e80a6a3f5 --- /dev/null +++ b/Tests/Functional/App/Type/YearScalarType.php @@ -0,0 +1,43 @@ +kind, [$valueNode]); + } + + return (int) str_replace(' AC', '', $valueNode->value); + } +} diff --git a/Tests/Functional/App/config/upload/config.yml b/Tests/Functional/App/config/upload/config.yml new file mode 100644 index 000000000..5bf686eb1 --- /dev/null +++ b/Tests/Functional/App/config/upload/config.yml @@ -0,0 +1,16 @@ +imports: + - { resource: ../config.yml } + +overblog_graphql: + errors_handler: + rethrow_internal_exceptions: true + definitions: + class_namespace: "Overblog\\GraphQLBundle\\Upload\\__DEFINITIONS__" + schema: + query: Query + mutation: Mutation + mappings: + types: + - + type: yaml + dir: "%kernel.root_dir%/config/upload/mapping" diff --git a/Tests/Functional/App/config/upload/mapping/schema.types.yaml b/Tests/Functional/App/config/upload/mapping/schema.types.yaml new file mode 100644 index 000000000..4f64f680f --- /dev/null +++ b/Tests/Functional/App/config/upload/mapping/schema.types.yaml @@ -0,0 +1,35 @@ +Query: + type: object + config: + fields: + foo: {type: String!} + +Upload: + type: custom-scalar + config: + scalarType: '@=newObject("Overblog\\GraphQLBundle\\Upload\\Type\\GraphQLUploadType")' + +Mutation: + type: object + config: + fields: + singleUpload: + type: String! + resolve: '@=args["file"].getBasename()' + args: + file: Upload! + multipleUpload: + type: '[String!]' + resolve: '@=[args["files"][0].getBasename(), args["files"][1].getBasename()]' + args: + files: '[Upload!]!' + serializationIsUnsupported: + type: Upload! + resolve: '@=args["file"]' + args: + file: Upload! + oldUpload: + type: String! + resolve: '@=args["file"]' + args: + file: String! diff --git a/Tests/Functional/Controller/GraphControllerTest.php b/Tests/Functional/Controller/GraphControllerTest.php index 91d829122..4b7f6cf29 100644 --- a/Tests/Functional/Controller/GraphControllerTest.php +++ b/Tests/Functional/Controller/GraphControllerTest.php @@ -230,7 +230,7 @@ public function testBatchEndpointWithEmptyQuery() /** * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - * @expectedExceptionMessage Only request with content type "application/json" is accepted. + * @expectedExceptionMessage Batching parser only accepts "application/json" or "multipart/form-data" content-type but got "". */ public function testBatchEndpointWrongContentType() { diff --git a/Tests/Functional/Upload/UploadTest.php b/Tests/Functional/Upload/UploadTest.php new file mode 100644 index 000000000..1f62762a3 --- /dev/null +++ b/Tests/Functional/Upload/UploadTest.php @@ -0,0 +1,140 @@ +assertUpload( + ['data' => ['singleUpload' => 'a.txt']], + [ + 'operations' => [ + 'query' => 'mutation($file: Upload!) { singleUpload(file: $file) }', + 'variables' => ['file' => null], + ], + 'map' => ['0' => ['variables.file']], + ], + ['0' => 'a.txt'] + ); + } + + public function testMultipleUpload() + { + $this->assertUpload( + ['data' => ['multipleUpload' => ['b.txt', 'c.txt']]], + [ + 'operations' => [ + 'query' => 'mutation($files: [Upload!]!) { multipleUpload(files: $files) }', + 'variables' => ['files' => [null, null]], + ], + 'map' => ['0' => ['variables.files.0'], '1' => ['variables.files.1']], + ], + ['0' => 'b.txt', 1 => 'c.txt'] + ); + } + + public function testBatching() + { + $this->assertUpload( + [ + [ + 'id' => 'singleUpload', + 'payload' => ['data' => ['singleUpload' => 'a.txt']], + ], + [ + 'id' => 'multipleUpload', + 'payload' => ['data' => ['multipleUpload' => ['b.txt', 'c.txt']]], + ], + ], + [ + 'operations' => [ + ['id' => 'singleUpload', 'query' => 'mutation($file: Upload!) { singleUpload(file: $file) }', 'variables' => ['file' => null]], + ['id' => 'multipleUpload', 'query' => 'mutation($files: [Upload!]!) { multipleUpload(files: $files) }', 'variables' => ['files' => [null, null]]], + ], + 'map' => ['0' => ['0.variables.file'], '1' => ['1.variables.files.0'], '2' => ['1.variables.files.1']], + ], + ['0' => 'a.txt', 1 => 'b.txt', 2 => 'c.txt'], + '/batch' + ); + } + + public function testOldUpload() + { + $this->assertUpload( + ['data' => ['oldUpload' => 'a.txt']], + [ + 'query' => 'mutation($file: String!) { oldUpload(file: $file) }', + 'variables' => ['file' => 'a.txt'], + ], + ['0' => 'a.txt'] + ); + } + + public function testSerializationIsUnsupported() + { + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('Upload scalar serialization unsupported.'); + $this->uploadRequest( + [ + 'operations' => [ + 'query' => 'mutation($file: Upload!) { serializationIsUnsupported(file: $file) }', + 'variables' => ['file' => null], + ], + 'map' => ['0' => ['variables.file']], + ], + ['0' => 'a.txt'] + ); + } + + public function testParseLiteralIsUnsupported() + { + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('Upload scalar literal unsupported.'); + $this->uploadRequest( + [ + 'operations' => [ + 'query' => 'mutation { singleUpload(file: {}) }', + 'variables' => ['file' => null], + ], + 'map' => ['0' => ['variables.file']], + ], + ['0' => 'a.txt'] + ); + } + + private function assertUpload(array $expected, array $parameters, array $files, $uri = '/') + { + $actual = $this->uploadRequest($parameters, $files, $uri); + $this->assertSame($expected, $actual); + } + + private function uploadRequest(array $parameters, array $files, $uri = '/') + { + $client = static::createClient(['test_case' => 'upload']); + $client->request( + 'POST', + $uri, + $parameters, + $this->createUploadedFiles($files), + ['CONTENT_TYPE' => 'multipart/form-data'] + ); + + return json_decode($client->getResponse()->getContent(), true); + } + + private function createUploadedFiles(array $fileNames) + { + $fixtureDir = __DIR__.'/fixtures/'; + $uploadedFiles = []; + foreach ($fileNames as $key => $fileName) { + $uploadedFiles[$key] = new UploadedFile($fixtureDir.'/'.$fileName, $fileName); + } + + return $uploadedFiles; + } +} diff --git a/Tests/Functional/Upload/fixtures/a.txt b/Tests/Functional/Upload/fixtures/a.txt new file mode 100644 index 000000000..651cda1a9 --- /dev/null +++ b/Tests/Functional/Upload/fixtures/a.txt @@ -0,0 +1 @@ +Alpha file content. diff --git a/Tests/Functional/Upload/fixtures/b.txt b/Tests/Functional/Upload/fixtures/b.txt new file mode 100644 index 000000000..7cc0a5791 --- /dev/null +++ b/Tests/Functional/Upload/fixtures/b.txt @@ -0,0 +1 @@ +Bravo file content. diff --git a/Tests/Functional/Upload/fixtures/c.txt b/Tests/Functional/Upload/fixtures/c.txt new file mode 100644 index 000000000..3adae37d8 --- /dev/null +++ b/Tests/Functional/Upload/fixtures/c.txt @@ -0,0 +1 @@ +Charlie file content. diff --git a/Tests/Request/UploadParserTraitTest.php b/Tests/Request/UploadParserTraitTest.php new file mode 100644 index 000000000..ed999f6a7 --- /dev/null +++ b/Tests/Request/UploadParserTraitTest.php @@ -0,0 +1,115 @@ +locationToPropertyAccessPath($location); + $this->assertSame($expected, $actual); + } + + /** + * @param array $operations + * @param array $map + * @param array $files + * @param array $expected + * @param string $message + * + * @dataProvider payloadProvider + */ + public function testMappingUploadFiles(array $operations, array $map, array $files, array $expected, $message) + { + $actual = $this->mappingUploadFiles($operations, $map, $files); + + $this->assertSame($expected, $actual, $message); + } + + public function testMappingUploadFilesFileNotFound() + { + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('File 0 is missing in the request.'); + $operations = ['query' => '', 'variables' => ['file' => null]]; + $map = ['0' => ['variables.file']]; + $files = []; + $this->mappingUploadFiles($operations, $map, $files); + } + + public function testMappingUploadFilesOperationPathNotFound() + { + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Map entry "variables.file" could not be localized in operations.'); + $operations = ['query' => '', 'variables' => []]; + $map = ['0' => ['variables.file']]; + $files = ['0' => new \stdClass()]; + $this->mappingUploadFiles($operations, $map, $files); + } + + public function testIsUploadPayload() + { + $this->assertFalse($this->isUploadPayload([])); + $this->assertFalse($this->isUploadPayload(['operations' => []])); + $this->assertFalse($this->isUploadPayload(['map' => []])); + $this->assertFalse($this->isUploadPayload(['operations' => null, 'map' => []])); + $this->assertFalse($this->isUploadPayload(['operations' => [], 'map' => null])); + $this->assertFalse($this->isUploadPayload(['operations' => null, 'map' => null])); + $this->assertTrue($this->isUploadPayload(['operations' => [], 'map' => []])); + } + + public function payloadProvider() + { + $files = ['0' => new \stdClass()]; + yield [ + ['query' => 'mutation($file: Upload!) { singleUpload(file: $file) { id } }', 'variables' => ['file' => null]], + ['0' => ['variables.file']], + $files, + ['query' => 'mutation($file: Upload!) { singleUpload(file: $file) { id } }', 'variables' => ['file' => $files['0']]], + 'single file', + ]; + $files = ['0' => new \stdClass(), 1 => new \stdClass()]; + yield [ + ['query' => 'mutation($files: [Upload!]!) { multipleUpload(files: $files) { id } }', 'variables' => ['files' => [null, null]]], + ['0' => ['variables.files.0'], '1' => ['variables.files.1']], + $files, + ['query' => 'mutation($files: [Upload!]!) { multipleUpload(files: $files) { id } }', 'variables' => ['files' => [$files['0'], $files[1]]]], + 'file list', + ]; + $files = [0 => new \stdClass(), '1' => new \stdClass(), '2' => new \stdClass()]; + yield [ + [ + ['query' => 'mutation($file: Upload!) { singleUpload(file: $file) { id } }', 'variables' => ['file' => null]], + ['query' => 'mutation($files: [Upload!]!) { multipleUpload(files: $files) { id } }', 'variables' => ['files' => [null, null]]], + ], + ['0' => ['0.variables.file'], '1' => ['1.variables.files.0'], '2' => ['1.variables.files.1']], + $files, + [ + ['query' => 'mutation($file: Upload!) { singleUpload(file: $file) { id } }', 'variables' => ['file' => $files[0]]], + ['query' => 'mutation($files: [Upload!]!) { multipleUpload(files: $files) { id } }', 'variables' => ['files' => [$files['1'], $files['2']]]], + ], + 'batching', + ]; + } + + public function locationsProvider() + { + yield ['variables.file', '[variables][file]']; + yield ['variables.files.0', '[variables][files][0]']; + yield ['variables.files.1', '[variables][files][1]']; + yield ['0.variables.file', '[0][variables][file]']; + yield ['1.variables.files.0', '[1][variables][files][0]']; + yield ['1.variables.files.1', '[1][variables][files][1]']; + } +} diff --git a/Tests/Upload/Type/GraphQLUploadTypeTest.php b/Tests/Upload/Type/GraphQLUploadTypeTest.php new file mode 100644 index 000000000..fec979a29 --- /dev/null +++ b/Tests/Upload/Type/GraphQLUploadTypeTest.php @@ -0,0 +1,33 @@ +expectException(InvariantViolation::class); + $this->expectExceptionMessage(sprintf('Upload should be instance of "Symfony\Component\HttpFoundation\File\File" but %s given.', $type)); + (new GraphQLUploadType('Upload'))->parseValue($invalidValue); + } + + public function invalidValueProvider() + { + yield [null, 'NULL']; + yield ['str', 'string']; + yield [1, 'integer']; + yield [new \stdClass(), 'stdClass']; + yield [true, 'boolean']; + yield [false, 'boolean']; + } +} diff --git a/Upload/Type/GraphQLUploadType.php b/Upload/Type/GraphQLUploadType.php new file mode 100644 index 000000000..00f820fcd --- /dev/null +++ b/Upload/Type/GraphQLUploadType.php @@ -0,0 +1,56 @@ + $name, + 'description' => sprintf( + 'The `%s` scalar type represents a file upload object that resolves an object containing `stream`, `filename`, `mimetype` and `encoding`.', + $name + ), + ]); + } + + /** + * {@inheritdoc} + */ + public function parseValue($value) + { + if (!$value instanceof File) { + throw new InvariantViolation(sprintf( + 'Upload should be instance of "%s" but %s given.', + File::class, + is_object($value) ? get_class($value) : gettype($value) + )); + } + + return $value; + } + + /** + * {@inheritdoc} + */ + public function serialize($value) + { + throw new InvariantViolation(sprintf('%s scalar serialization unsupported.', $this->name)); + } + + /** + * {@inheritdoc} + */ + public function parseLiteral($valueNode) + { + throw new InvariantViolation(sprintf('%s scalar literal unsupported.', $this->name)); + } +}