diff --git a/Readme.md b/Readme.md index d660d35a..30b2c21c 100644 --- a/Readme.md +++ b/Readme.md @@ -127,7 +127,7 @@ config/graphql.php - [Schemas](#schemas) - [Creating a query](#creating-a-query) - [Creating a mutation](#creating-a-mutation) -- [Adding validation to mutation](#adding-validation-to-mutation) +- [Input Validation](#validation) #### Advanced Usage - [Query variables](docs/advanced.md#query-variables) @@ -418,91 +418,179 @@ if you use homestead: http://homestead.app/graphql?query=mutation+users{updateUserPassword(id: "1", password: "newpassword"){id,email}} ``` -#### Adding validation to mutation +### Validation -It is possible to add validation rules to mutation. It uses the laravel `Validator` to performs validation against the `args`. +It is possible to add additional validation rules to inputs, using the Laravel `Validator` to perform validation against the `args`. +Validation is mostly used for Mutations, but can also be applied to Queries that take arguments. -When creating a mutation, you can add a method to define the validation rules that apply by doing the following: +Be aware that GraphQL has native types to define a field as either a List or as NonNull. Use those wrapping +types instead of native Laravel validation via `array` or `required`. This way, those constraints are +reflected through the schema and are validated by the underlying GraphQL implementation. -```php -namespace App\GraphQL\Mutation; +#### Rule definition -use GraphQL; -use GraphQL\Type\Definition\Type; -use Folklore\GraphQL\Support\Mutation; -use App\User; +##### Inline Array +The preferred way to add rules is to inline them with the arguments of Mutations or Queries. + +```php +//... class UpdateUserEmailMutation extends Mutation { - protected $attributes = [ - 'name' => 'UpdateUserEmail' - ]; - - public function type() - { - return GraphQL::type('User'); - } + //... public function args() - { - return [ - 'id' => ['name' => 'id', 'type' => Type::string()], - 'email' => ['name' => 'email', 'type' => Type::string()] - ]; - } + { + return [ + 'email' => [ + 'name' => 'email', + 'type' => Type::string(), + 'rules' => [ + 'email', + 'exists:users,email' + ] + ], + ]; + } +} +``` - public function rules() - { - return [ - 'id' => ['required'], - 'email' => ['required', 'email'] - ]; - } +##### Inline Closure - public function resolve($root, $args) - { - $user = User::find($args['id']); +Rules may also be defined as closures. They are called before the resolve function of the field is called +and receive the same arguments. - if (!$user) { - return null; - } +````php +'phone' => [ + 'name' => 'phone', + 'type' => Type::nonNull(Type::string()), + 'rules' => function ($root, $args, $context, \GraphQL\Type\Definition\ResolveInfo $resolveInfo){ + return []; + } +], +```` - $user->email = $args['email']; - $user->save(); +##### Rule Overwrites - return $user; - } +You can overwrite inline rules of fields or nested Input Objects by defining them like this: + +````php +public function rules() +{ + return [ + 'email' => ['email', 'min:10'], + 'nested.value' => ['alpha_num'], + ]; } -``` +```` -Alternatively you can define rules with each args +Be aware that those rules are always applied, even if the argument is not given. You may want to prefix +them with `sometimes` if the rule is optional. -```php -class UpdateUserEmailMutation extends Mutation +#### Required Arguments + +GraphQL has a built-in way of defining arguments as required, simply wrap them in a `Type::nonNull()`. + +````php +'id' => [ + 'name' => 'id', + 'type' => Type::nonNull(Type::string()), +], +```` + +The presence of such arguments is checked before the arguments even reach the resolver, so there is +no need to validate them through an additional rule, so you will not ever need `required`. +Defining required arguments through the Non-Null type is preferable because it shows up in the schema definition. + +Because GraphQL arguments are optional by default, the validation rules for them will only be applied if they are present. +If you need more sophisticated validation of fields, using additional rules like `required_with` is fine. + +#### Input Object Rules + +You may use Input Objects as arguments like this: + +````php +'name' => [ + 'name' => 'name', + 'type' => GraphQL::type('NameInputObject') +], +```` + +Rules defined in the Input Object are automatically added to the validation, even if nested Input Objects are used. +The definition of those rules looks like this: + +````php + 'NameInputObject' + ]; + + public function fields() { return [ - 'id' => [ - 'name' => 'id', + 'first' => [ + 'name' => 'first', 'type' => Type::string(), - 'rules' => ['required'] + 'rules' => ['alpha'] + ], + 'last' => [ + 'name' => 'last', + 'type' => Type::nonNull(Type::string()), + 'rules' => ['alpha'] ], - 'email' => [ - 'name' => 'email', - 'type' => Type::string(), - 'rules' => ['required', 'email'] - ] ]; } +} +```` - //... +Now, the rules in here ensure that if a name is passed to base field, it must contain at least a +last name, and the first and last name can only contain alphabetic characters. + +#### Array Validation + +GraphQL allows arguments to be defined as lists by wrapping them in `Type::listOf()`. +In most cases it is desirable to apply validation rules to the underlying elements of the array. +If a type is wrapped as a list, the inline rules are automatically applied to the underlying +elements. + +````php +'links' => [ + 'name' => 'links', + 'type' => Type::listOf(Type::string()), + 'rules' => ['url', 'distinct'], +], +```` + +If validation on the array itself is required, you can do so by defining those rules seperately: + +````php +public function rules() +{ + return [ + 'links' => ['max:10'] + ]; } -``` +```` + +This ensures that `links` is an array of at most 10, distinct urls. + +#### Response format + +When you execute a field with arguments, it will return the validation errors. +Since the GraphQL specification defines a certain format for errors, the validation error messages +are added to the error object as an extra `validation` attribute. -When you execute a mutation, it will returns the validation errors. Since GraphQL specifications define a certain format for errors, the validation errors messages are added to the error object as a extra `validation` attribute. To find the validation error, you should check for the error with a `message` equals to `'validation'`, then the `validation` attribute will contain the normal errors messages returned by the Laravel Validator. +To find the validation error, you should check for the error with a `message` +equals to `'validation'`, then the `validation` attribute will contain the normal +errors messages returned by the Laravel Validator. ```json { diff --git a/src/Folklore/GraphQL/Console/stubs/field.stub b/src/Folklore/GraphQL/Console/stubs/field.stub index e7003d00..b0c14e0d 100644 --- a/src/Folklore/GraphQL/Console/stubs/field.stub +++ b/src/Folklore/GraphQL/Console/stubs/field.stub @@ -4,23 +4,65 @@ namespace DummyNamespace; use GraphQL\Type\Definition\Type; use Folklore\GraphQL\Support\Field; +use GraphQL\Type\Definition\ResolveInfo; -class DummyClass extends Field { +class DummyClass extends Field +{ protected $attributes = [ + 'name' => 'DummyField', 'description' => 'A field' ]; + /** + * Define a GraphQL Type which is returned by this field. + * + * @return \Folklore\GraphQL\Support\Type + */ public function type() { - return Type::string(); + return Type::listOf(Type::string()); } + /** + * Define the arguments expected by the field. + * + * @return array + */ public function args() + { + return [ + 'example' => [ + 'name' => 'example', + 'type' => Type::nonNull(Type::string()), + 'rules' => ['alpha', 'not_in:forbidden,value'] + ] + ]; + } + + /** + * Overwrite rules at any part in the tree of field arguments. + * + * The rules defined in here take precedence over the rules that are + * defined inline or inferred from nested Input Objects. + * + * @return array + */ + public function rules() { return []; } - protected function resolve($root, $args) + /** + * Return a result for the field which should match up with its return type. + * + * @param $root + * @param $args + * @param $context + * @param ResolveInfo $info + * @return array + */ + public function resolve($root, $args, $context, ResolveInfo $info) { + return []; } } diff --git a/src/Folklore/GraphQL/Console/stubs/mutation.stub b/src/Folklore/GraphQL/Console/stubs/mutation.stub index 837319bf..d3d0d146 100644 --- a/src/Folklore/GraphQL/Console/stubs/mutation.stub +++ b/src/Folklore/GraphQL/Console/stubs/mutation.stub @@ -5,7 +5,6 @@ namespace DummyNamespace; use Folklore\GraphQL\Support\Mutation; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; -use GraphQL; class DummyClass extends Mutation { @@ -14,18 +13,54 @@ class DummyClass extends Mutation 'description' => 'A mutation' ]; + /** + * Define a GraphQL Type which is returned by this field. + * + * @return \Folklore\GraphQL\Support\Type + */ public function type() { return Type::listOf(Type::string()); } + /** + * Define the arguments expected by the field. + * + * @return array + */ public function args() { return [ - + 'example' => [ + 'name' => 'example', + 'type' => Type::nonNull(Type::string()), + 'rules' => ['alpha', 'not_in:forbidden,value'] + ] ]; } + /** + * Overwrite rules at any part in the tree of field arguments. + * + * The rules defined in here take precedence over the rules that are + * defined inline or inferred from nested Input Objects. + * + * @return array + */ + public function rules() + { + return []; + } + + /** + * Return a result for the field which should match up with its return type. + * + * @param $root + * @param $args + * @param $context + * @param ResolveInfo $info + * @return array + */ public function resolve($root, $args, $context, ResolveInfo $info) { return []; diff --git a/src/Folklore/GraphQL/Console/stubs/query.stub b/src/Folklore/GraphQL/Console/stubs/query.stub index 6ec158bc..95464db5 100644 --- a/src/Folklore/GraphQL/Console/stubs/query.stub +++ b/src/Folklore/GraphQL/Console/stubs/query.stub @@ -5,7 +5,6 @@ namespace DummyNamespace; use Folklore\GraphQL\Support\Query; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; -use GraphQL; class DummyClass extends Query { @@ -14,18 +13,54 @@ class DummyClass extends Query 'description' => 'A query' ]; + /** + * Define a GraphQL Type which is returned by this field. + * + * @return \Folklore\GraphQL\Support\Type + */ public function type() { return Type::listOf(Type::string()); } + /** + * Define the arguments expected by the field. + * + * @return array + */ public function args() { return [ - + 'example' => [ + 'name' => 'example', + 'type' => Type::nonNull(Type::string()), + 'rules' => ['alpha', 'not_in:forbidden,value'] + ] ]; } + /** + * Overwrite rules at any part in the tree of field arguments. + * + * The rules defined in here take precedence over the rules that are + * defined inline or inferred from nested Input Objects. + * + * @return array + */ + public function rules() + { + return []; + } + + /** + * Return a result for the field which should match up with its return type. + * + * @param $root + * @param $args + * @param $context + * @param ResolveInfo $info + * @return array + */ public function resolve($root, $args, $context, ResolveInfo $info) { return []; diff --git a/src/Folklore/GraphQL/Error/ValidationError.php b/src/Folklore/GraphQL/Error/ValidationError.php index b87a50b8..184ce506 100644 --- a/src/Folklore/GraphQL/Error/ValidationError.php +++ b/src/Folklore/GraphQL/Error/ValidationError.php @@ -1,26 +1,39 @@ validator = $validator; return $this; } - + + /** + * @return Validator + */ public function getValidator() { return $this->validator; } - + + /** + * @return array|\Illuminate\Support\MessageBag + */ public function getValidatorMessages() { - return $this->validator ? $this->validator->messages():[]; + return $this->validator ? $this->validator->messages() : []; } } diff --git a/src/Folklore/GraphQL/GraphQL.php b/src/Folklore/GraphQL/GraphQL.php index 53cfb98c..230fbfdc 100644 --- a/src/Folklore/GraphQL/GraphQL.php +++ b/src/Folklore/GraphQL/GraphQL.php @@ -1,23 +1,17 @@ getAttributes(); } - public function type() + /** + * Get the attributes from the container. + * + * @return array + */ + public function getAttributes() { - return null; + return array_merge( + $this->attributes, + [ + 'args' => $this->args(), + 'type' => $this->type(), + 'resolve' => $this->getResolver(), + ] + ); } + /** + * Define the arguments expected by the field. + * + * @return array + */ public function args() { return []; } + /** + * Define a GraphQL Type which is returned by this field. + * + * @return \Folklore\GraphQL\Support\Type + */ + public abstract function type(); + + /** + * Returns a function that wraps the resolve function with authentication and validation checks. + * + * @return \Closure + */ protected function getResolver() { - if (!method_exists($this, 'resolve')) { - return null; - } + return function () { + $resolutionArguments = func_get_args(); - $resolver = array($this, 'resolve'); - $authenticate = [$this, 'authenticated']; - $authorize = [$this, 'authorize']; - - return function () use ($resolver, $authorize, $authenticate) { - $args = func_get_args(); - - // Authenticated - if (call_user_func_array($authenticate, $args) !== true) { + // Check authentication first + if (call_user_func_array([$this, 'authenticated'], $resolutionArguments) !== true) { throw new AuthorizationError('Unauthenticated'); } - // Authorize - if (call_user_func_array($authorize, $args) !== true) { + // After authentication, check specific authorization + if (call_user_func_array([$this, 'authorize'], $resolutionArguments) !== true) { throw new AuthorizationError('Unauthorized'); } - return call_user_func_array($resolver, $args); + call_user_func_array([$this, 'validate'], $resolutionArguments); + + + return call_user_func_array([$this, 'resolve'], $resolutionArguments); }; } /** - * Get the attributes from the container. + * Return a result for the field. + * + * @param $root + * @param $args + * @param $context + * @param ResolveInfo $info + * @return mixed + */ + public function resolve($root, $args, $context, ResolveInfo $info) + { + // Use the default resolver, can be overridden in custom Fields, Queries or Mutations + // This enables us to still apply authentication, authorization and validation upon the query + return call_user_func(config('graphql.defaultFieldResolver')); + } + + /** + * Dynamically retrieve the value of an attribute. + * + * @param string $key + * @return mixed + */ + public function __get($key) + { + $attributes = $this->getAttributes(); + return isset($attributes[$key]) ? $attributes[$key] : null; + } + + /** + * Dynamically check if an attribute is set. + * + * @param string $key + * @return bool + */ + public function __isset($key) + { + $attributes = $this->getAttributes(); + return isset($attributes[$key]); + } + + /** + * Overwrite rules at any part in the tree of field arguments. + * + * The rules defined in here take precedence over the rules that are + * defined inline or inferred from nested Input Objects. * * @return array */ - public function getAttributes() + public function rules() { - $attributes = $this->attributes(); - $args = $this->args(); + return []; + } - $attributes = array_merge($this->attributes, [ - 'args' => $args - ], $attributes); + /** + * Gather all the rules and throw if invalid. + */ + protected function validate() + { + $resolutionArguments = func_get_args(); + $inputArguments = array_get($resolutionArguments, 1, []); - $type = $this->type(); - if (isset($type)) { - $attributes['type'] = $type; - } + $argumentRules = $this->getRulesForArguments($this->args(), $inputArguments); + $explicitRules = call_user_func_array([$this, 'rules'], $resolutionArguments); + $argumentRules = array_merge($argumentRules, $explicitRules); - $resolver = $this->getResolver(); - if (isset($resolver)) { - $attributes['resolve'] = $resolver; + foreach ($argumentRules as $key => $rules) { + $resolvedRules[$key] = $this->resolveRules($rules, $resolutionArguments); } - return $attributes; + if (isset($resolvedRules)) { + $validationErrorMessages = call_user_func_array([$this, 'validationErrorMessages'], $resolutionArguments); + $validator = $this->getValidator($inputArguments, $resolvedRules, $validationErrorMessages); + if ($validator->fails()) { + throw (new ValidationError('validation'))->setValidator($validator); + } + } } /** - * Convert the Fluent instance to an array. + * Get the combined explicit and type-inferred rules. * + * @param $argDefinitions + * @param $argValues * @return array */ - public function toArray() + protected function getRulesForArguments($argDefinitions, $argValues) { - return $this->getAttributes(); + $rules = []; + + foreach ($argValues as $name => $value) { + $definition = $argDefinitions[$name]; + + $typeAndKey = $this->unwrapType($definition['type'], $name); + $key = $typeAndKey['key']; + $type = $typeAndKey['type']; + + // Get rules that are directly defined on the field + if (isset($definition['rules'])) { + $rules[$key] = $definition['rules']; + } + + if ($type instanceof InputObjectType) { + $rules = array_merge($rules, $this->getRulesFromInputObjectType($key, $type, $value)); + } + } + + return $rules; } /** - * Dynamically retrieve the value of an attribute. - * - * @param string $key + * @param $type + * @param $key + * @return array + */ + protected function unwrapType($type, $key) + { + // Unpack until we get to the root type + while ($type instanceof WrappingType) { + if ($type instanceof ListOfType) { + // Add this to the prefix to allow Laravel validation for arrays + $key .= '.*'; + } + + $type = $type->getWrappedType(); + } + + return [ + 'type' => $type, + 'key' => $key, + ]; + } + + protected function getRulesFromInputObjectType($parentKey, InputObjectType $inputObject, $values) + { + $rules = []; + // At this point we know we expect InputObjects, but there might be more then one value + // Since they might have different fields, we have to look at each of them individually + // If we have string keys, we are dealing with the values themselves + if ($this->hasStringKeys($values)) { + foreach ($values as $name => $value) { + $key = "{$parentKey}.{$name}"; + + $field = $inputObject->getFields()[$name]; + + $typeAndKey = $this->unwrapType($field->type, $key); + $key = $typeAndKey['key']; + $type = $typeAndKey['type']; + + if (isset($field->rules)) { + $rules[$key] = $field->rules; + } + + if ($type instanceof InputObjectType) { + // Recursively call the parent method to get nested rules, passing in the new prefix + $rules = array_merge($rules, $this->getRulesFromInputObjectType($key, $type, $value)); + } + } + } else { + // Go one level deeper so we deal with actual values + foreach ($values as $nestedValues) { + $rules = array_merge($rules, $this->getRulesFromInputObjectType($parentKey, $inputObject, $nestedValues)); + } + } + + return $rules; + } + + /** + * @param array $array + * @return bool + */ + protected function hasStringKeys(array $array) + { + return count(array_filter(array_keys($array), 'is_string')) > 0; + } + + /** + * @param $rules + * @param $arguments * @return mixed */ - public function __get($key) + protected function resolveRules($rules, $arguments) { - $attributes = $this->getAttributes(); - return isset($attributes[$key]) ? $attributes[$key]:null; + // Rules can be defined as closures that are passed the resolution arguments + if (is_callable($rules)) { + return call_user_func_array($rules, $arguments); + } + + return $rules; } /** - * Dynamically check if an attribute is set. + * @param $args + * @param $rules + * @param array $messages * - * @param string $key - * @return void + * @return Validator */ - public function __isset($key) + protected function getValidator($args, $rules, $messages = []) { - $attributes = $this->getAttributes(); - return isset($attributes[$key]); + /** @var Validator $validator */ + $validator = app('validator')->make($args, $rules, $messages); + if (method_exists($this, 'withValidator')) { + $this->withValidator($validator, $args); + } + + return $validator; + } + + /** + * Return an array of custom validation error messages. + * + * @return array + */ + protected function validationErrorMessages($root, $args, $context) + { + return []; } } diff --git a/src/Folklore/GraphQL/Support/Mutation.php b/src/Folklore/GraphQL/Support/Mutation.php index e4b710be..bed633f7 100644 --- a/src/Folklore/GraphQL/Support/Mutation.php +++ b/src/Folklore/GraphQL/Support/Mutation.php @@ -2,11 +2,12 @@ namespace Folklore\GraphQL\Support; -use Validator; -use Folklore\GraphQL\Error\ValidationError; -use Folklore\GraphQL\Support\Traits\ShouldValidate; - -class Mutation extends Field +/** + * Mutations are just fields which are expected to change state on the server. + * + * Class Mutation + * @package Folklore\GraphQL\Support + */ +abstract class Mutation extends Field { - use ShouldValidate; } diff --git a/src/Folklore/GraphQL/Support/Query.php b/src/Folklore/GraphQL/Support/Query.php index c59e09ae..12c70f44 100644 --- a/src/Folklore/GraphQL/Support/Query.php +++ b/src/Folklore/GraphQL/Support/Query.php @@ -2,7 +2,12 @@ namespace Folklore\GraphQL\Support; -class Query extends Field +/** + * Queries are simply Fields which are supposed to be idempotent. + * + * Class Query + * @package Folklore\GraphQL\Support + */ +abstract class Query extends Field { - } diff --git a/src/Folklore/GraphQL/Support/Traits/ShouldValidate.php b/src/Folklore/GraphQL/Support/Traits/ShouldValidate.php deleted file mode 100644 index 03ecf5f2..00000000 --- a/src/Folklore/GraphQL/Support/Traits/ShouldValidate.php +++ /dev/null @@ -1,133 +0,0 @@ -args() as $name => $arg) { - if (isset($arg['rules'])) { - $argsRules[$name] = $this->resolveRules($arg['rules'], $arguments); - } - - if (isset($arg['type'])) { - $argsRules = array_merge($argsRules, $this->inferRulesFromType($arg['type'], $name, $arguments)); - } - } - - return array_merge($rules, $argsRules); - } - - public function resolveRules($rules, $arguments) - { - if (is_callable($rules)) { - return call_user_func_array($rules, $arguments); - } - - return $rules; - } - - public function inferRulesFromType($type, $prefix, $resolutionArguments) - { - $rules = []; - - // if it is an array type, add an array validation component - if ($type instanceof ListOfType) { - $prefix = "{$prefix}.*"; - } - - // make sure we are dealing with the actual type - if ($type instanceof WrappingType) { - $type = $type->getWrappedType(); - } - - // if it is an input object type - the only type we care about here... - if ($type instanceof InputObjectType) { - // merge in the input type's rules - $rules = array_merge($rules, $this->getInputTypeRules($type, $prefix, $resolutionArguments)); - } - - // Ignore scalar types - - return $rules; - } - - public function getInputTypeRules(InputObjectType $input, $prefix, $resolutionArguments) - { - $rules = []; - - foreach ($input->getFields() as $name => $field) { - $key = "{$prefix}.{$name}"; - - // get any explicitly set rules - if (isset($field->rules)) { - $rules[$key] = $this->resolveRules($field->rules, $resolutionArguments); - } - - // then recursively call the parent method to see if this is an - // input object, passing in the new prefix - $rules = array_merge($rules, $this->inferRulesFromType($field->type, $key, $resolutionArguments)); - } - - return $rules; - } - - protected function getValidator($args, $rules, $messages = []) - { - $validator = app('validator')->make($args, $rules, $messages); - if (method_exists($this, 'withValidator')) { - $this->withValidator($validator, $args); - } - - return $validator; - } - - protected function getResolver() - { - $resolver = parent::getResolver(); - if (!$resolver) { - return null; - } - - return function () use ($resolver) { - $arguments = func_get_args(); - - $rules = call_user_func_array([$this, 'getRules'], $arguments); - $validationErrorMessages = call_user_func_array([$this, 'validationErrorMessages'], $arguments); - if (sizeof($rules)) { - $args = array_get($arguments, 1, []); - $validator = $this->getValidator($args, $rules, $validationErrorMessages); - if ($validator->fails()) { - throw with(new ValidationError('validation'))->setValidator($validator); - } - } - - return call_user_func_array($resolver, $arguments); - }; - } -} diff --git a/src/config/config.php b/src/config/config.php index 0d51e6e6..c385e7ed 100644 --- a/src/config/config.php +++ b/src/config/config.php @@ -152,17 +152,17 @@ ], /* - * Overrides the default field resolver - * Useful to setup default loading of eager relationships + * Overrides the default field resolver. + * + * This may be useful to setup default loading of eager relationships * * Example: * * 'defaultFieldResolver' => function ($root, $args, $context, $info) { - * // take a look at the defaultFieldResolver in - * // https://github.com/webonyx/graphql-php/blob/master/src/Executor/Executor.php + * // Implement your custom functionality here * }, */ - 'defaultFieldResolver' => null, + 'defaultFieldResolver' => [\GraphQL\Executor\Executor::class, 'defaultFieldResolver'], /* * The types available in the application. You can access them from the diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index b57c6d23..46c4d320 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -1,8 +1,6 @@ ExamplesRootQuery::class ], 'mutation' => [ - 'updateExample' => UpdateExampleMutation::class + 'exampleMutation' => ExampleMutation::class ] ], 'custom' => [ @@ -38,7 +36,7 @@ protected function getEnvironmentSetUp($app) 'examplesCustom' => ExamplesQuery::class ], 'mutation' => [ - 'updateExampleCustom' => UpdateExampleMutation::class + 'exampleMutationCustom' => ExampleMutation::class ] ], 'shorthand' => BuildSchema::build(' @@ -62,7 +60,9 @@ protected function getEnvironmentSetUp($app) 'types' => [ 'Example' => ExampleType::class, - CustomExampleType::class + CustomExampleType::class, + 'ExampleParentInputObject' => ExampleParentInputObject::class, + 'ExampleChildInputObject' => ExampleChildInputObject::class, ], 'security' => [ @@ -79,7 +79,7 @@ public function testRouteQuery() 'query' => $this->queries['examplesCustom'] ]); - $this->assertEquals($response->getStatusCode(), 200); + $this->assertEquals(200, $response->getStatusCode()); $content = $response->getData(true); $this->assertArrayHasKey('data', $content); @@ -88,10 +88,13 @@ public function testRouteQuery() public function testRouteMutation() { $response = $this->call('POST', '/graphql_test/mutation', [ - 'query' => $this->queries['updateExampleCustom'] + 'query' => $this->queries['exampleMutation'], + 'params' => [ + 'required' => 'test' + ], ]); - $this->assertEquals($response->getStatusCode(), 200); + $this->assertEquals(200, $response->getStatusCode()); $content = $response->getData(true); $this->assertArrayHasKey('data', $content); @@ -130,7 +133,7 @@ public function testVariablesInputName() ] ]); - $this->assertEquals($response->getStatusCode(), 200); + $this->assertEquals(200, $response->getStatusCode()); $content = $response->getData(true); $this->assertArrayHasKey('data', $content); @@ -150,7 +153,7 @@ public function testVariablesInputNameForShorthandResolver() ], ]); - $this->assertEquals($response->getStatusCode(), 200); + $this->assertEquals(200, $response->getStatusCode()); $content = $response->getData(true); $this->assertArrayHasKey('data', $content); @@ -181,6 +184,6 @@ public function testErrorFormatter() 'graphql.error_formatter' => [$error, 'formatError'] ]); - $result = GraphQL::query($this->queries['examplesWithError']); + GraphQL::query($this->queries['examplesWithError']); } } diff --git a/tests/FieldTest.php b/tests/FieldTest.php index 53817610..90654841 100644 --- a/tests/FieldTest.php +++ b/tests/FieldTest.php @@ -1,16 +1,26 @@ getFieldClass(); + return new $class(); + } + /** * Test get attributes * @@ -18,8 +28,7 @@ protected function getFieldClass() */ public function testGetAttributes() { - $class = $this->getFieldClass(); - $field = new $class(); + $field = $this->getFieldInstance(); $attributes = $field->getAttributes(); $this->assertArrayHasKey('name', $attributes); @@ -32,11 +41,11 @@ public function testGetAttributes() } /** - * Test resolve closure + * Test the calling of a custom resolve function. * * @test */ - public function testResolve() + public function testResolveFunctionIsCalled() { $class = $this->getFieldClass(); $field = $this->getMockBuilder($class) @@ -47,7 +56,7 @@ public function testResolve() ->method('resolve'); $attributes = $field->getAttributes(); - $attributes['resolve'](null, [], [], null); + $attributes['resolve'](null, [], [], new \GraphQL\Type\Definition\ResolveInfo([])); } /** @@ -57,8 +66,7 @@ public function testResolve() */ public function testToArray() { - $class = $this->getFieldClass(); - $field = new $class(); + $field = $this->getFieldInstance(); $array = $field->toArray(); $this->assertInternalType('array', $array); diff --git a/tests/GraphQLQueryTest.php b/tests/GraphQLQueryTest.php index f8a71a99..d0d2cc7b 100644 --- a/tests/GraphQLQueryTest.php +++ b/tests/GraphQLQueryTest.php @@ -1,11 +1,5 @@ queries['examplesWithValidation']); + $result = GraphQL::query($this->queries['examplesWithValidation'], [ + 'index' => 0 + ]); $this->assertArrayHasKey('data', $result); - $this->assertArrayHasKey('errors', $result); - $this->assertArrayHasKey('validation', $result['errors'][0]); - $this->assertTrue($result['errors'][0]['validation']->has('index')); + $this->assertArrayNotHasKey('errors', $result); } /** - * Test query with validation without error + * Test query with validation error. * * @test */ - public function testQueryWithValidation() + public function testQueryWithValidationError() { $result = GraphQL::query($this->queries['examplesWithValidation'], [ - 'index' => 0 + // The validation requires this to be below 100 + 'index' => 9001 ]); $this->assertArrayHasKey('data', $result); - $this->assertArrayNotHasKey('errors', $result); + $this->assertArrayHasKey('errors', $result); + $this->assertArrayHasKey('validation', $result['errors'][0]); + $this->assertTrue($result['errors'][0]['validation']->has('index')); } } diff --git a/tests/GraphQLTest.php b/tests/GraphQLTest.php index 143d6580..910d250d 100644 --- a/tests/GraphQLTest.php +++ b/tests/GraphQLTest.php @@ -1,12 +1,12 @@ assertGraphQLSchema($schema); $this->assertGraphQLSchemaHasQuery($schema, 'examples'); - $this->assertGraphQLSchemaHasMutation($schema, 'updateExample'); + $this->assertGraphQLSchemaHasMutation($schema, 'exampleMutation'); $this->assertArrayHasKey('Example', $schema->getTypeMap()); } @@ -58,7 +58,7 @@ public function testSchemaWithName() $this->assertGraphQLSchema($schema); $this->assertGraphQLSchemaHasQuery($schema, 'examplesCustom'); - $this->assertGraphQLSchemaHasMutation($schema, 'updateExampleCustom'); + $this->assertGraphQLSchemaHasMutation($schema, 'exampleMutationCustom'); $this->assertArrayHasKey('Example', $schema->getTypeMap()); } @@ -74,7 +74,7 @@ public function testSchemaWithArray() 'examplesCustom' => ExamplesQuery::class ], 'mutation' => [ - 'updateExampleCustom' => UpdateExampleMutation::class + 'updateExampleCustom' => ExampleMutation::class ], 'types' => [ CustomExampleType::class @@ -280,7 +280,7 @@ public function testAddSchema() 'examplesCustom' => ExamplesQuery::class ], 'mutation' => [ - 'updateExampleCustom' => UpdateExampleMutation::class + 'updateExampleCustom' => ExampleMutation::class ], 'types' => [ CustomExampleType::class diff --git a/tests/MutationTest.php b/tests/MutationTest.php index 4dc4d2d5..ad8d5b35 100644 --- a/tests/MutationTest.php +++ b/tests/MutationTest.php @@ -1,147 +1,182 @@ set('graphql.types', [ - 'Example' => ExampleType::class, - 'ExampleValidationInputObject' => ExampleValidationInputObject::class, - 'ExampleNestedValidationInputObject' => ExampleNestedValidationInputObject::class, + $this->callResolveWithInput([ + 'email_inline_rules' => 'not-an-email' ]); } /** - * Test get rules. + * Validation error messages are correctly constructed and thrown. * * @test */ - public function testGetRules() + public function testCustomValidationErrorMessages() { - $class = $this->getFieldClass(); - $field = new $class(); - $rules = $field->getRules(); - - $this->assertInternalType('array', $rules); - $this->assertArrayHasKey('test', $rules); - $this->assertArrayHasKey('test_with_rules', $rules); - $this->assertArrayHasKey('test_with_rules_closure', $rules); - $this->assertEquals($rules['test'], ['required']); - $this->assertEquals($rules['test_with_rules'], ['required']); - $this->assertEquals($rules['test_with_rules_closure'], ['required']); - $this->assertEquals($rules['test_with_rules_input_object'], ['required']); - $this->assertEquals(array_get($rules, 'test_with_rules_input_object.val'), ['required']); - $this->assertEquals(array_get($rules, 'test_with_rules_input_object.nest'), ['required']); - $this->assertEquals(array_get($rules, 'test_with_rules_input_object.nest.email'), ['email']); - $this->assertEquals(array_get($rules, 'test_with_rules_input_object.list'), ['required']); - $this->assertEquals(array_get($rules, 'test_with_rules_input_object.list.*.email'), ['email']); + try { + $this->callResolveWithInput([ + 'email_inline_rules' => 'not-an-email', + 'input_object' => [ + 'child' => [ + 'email' => 'not-an-email' + ], + ] + ]); + } catch (\Folklore\GraphQL\Error\ValidationError $e) { + $messages = $e->getValidatorMessages(); + + // The custom validation error message should override the default + $this->assertEquals('Has to be a valid email.', $messages->first('email_inline_rules')); + $this->assertEquals('Invalid email: not-an-email', $messages->first('input_object.child.email')); + } } /** - * Test resolve. - * * @test */ - public function testResolve() + public function testArrayValidationIsApplied() { - $class = $this->getFieldClass(); - $field = $this->getMockBuilder($class) - ->setMethods(['resolve']) - ->getMock(); - - $field->expects($this->once()) - ->method('resolve'); - - $attributes = $field->getAttributes(); - $attributes['resolve'](null, [ - 'test' => 'test', - 'test_with_rules' => 'test', - 'test_with_rules_closure' => 'test', - 'test_with_rules_input_object' => [ - 'val' => 'test', - 'nest' => ['email' => 'test@test.com'], - 'list' => [ - ['email' => 'test@test.com'], + try { + $this->callResolveWithInput([ + 'email_list' => ['not-an-email', 'valid@email.com'], + 'email_list_of_lists' => [ + ['valid@email.com'], + ['not-an-email'], ], - ], - ], [], null); + ]); + } catch (\Folklore\GraphQL\Error\ValidationError $e) { + $messages = $e->getValidatorMessages(); + + $messageKeys = $messages->keys(); + $expectedKeys = [ + 'email_list.0', + 'email_list_of_lists.1.0', + ]; + // Sort the arrays before comparison so that order does not matter + sort($expectedKeys); + sort($messageKeys); + // Ensure that validation errors occurred only where necessary + $this->assertEquals($expectedKeys, $messageKeys, 'Not all the right fields were validated.'); + } } /** - * Test resolve throw validation error. - * * @test - * @expectedException \Folklore\GraphQL\Error\ValidationError */ - public function testResolveThrowValidationError() + public function testRulesForNestedInputObjects() { - $class = $this->getFieldClass(); - $field = new $class(); + try { + $this->callResolveWithInput([ + 'input_object' => [ + 'child' => [ + 'email' => 'not-an-email' + ], + 'self' => [ + 'self' => [ + 'alpha' => 'Not alphanumeric !"ยง)' + ], + 'child_list' => [ + ['email' => 'abc'], + ['email' => 'def'] + ] + ] + ] + ]); + } catch (\Folklore\GraphQL\Error\ValidationError $e) { + $validator = $e->getValidator(); + $rules = $validator->getRules(); - $attributes = $field->getAttributes(); - $attributes['resolve'](null, [], [], null); + $this->assertEquals(['email'], $rules['input_object.child.email']); + $this->assertEquals(['alpha'], $rules['input_object.self.self.alpha']); + $this->assertEquals(['email'], $rules['input_object.self.child_list.0.email']); + $this->assertEquals(['email'], $rules['input_object.self.child_list.1.email']); + } } /** - * Test validation error. - * * @test */ - public function testValidationError() + public function testExplicitRulesOverwriteInlineRules() { - $class = $this->getFieldClass(); - $field = new $class(); + try { + $this->callResolveWithInput([ + 'email_seperate_rules' => 'asdf' + ]); + } catch (\Folklore\GraphQL\Error\ValidationError $e) { + $validator = $e->getValidator(); + $rules = $validator->getRules(); - $attributes = $field->getAttributes(); + $this->assertEquals(['email'], $rules['email_seperate_rules']); + } + } + /** + * @test + */ + public function testCanValidateArraysThroughSeperateRules() + { try { - $attributes['resolve'](null, [], [], null); + $this->callResolveWithInput([ + 'email_list' => [ + 'invalid', + 'asdf@asdf.de', + 'asdf@asdf.de', + ] + ]); } catch (\Folklore\GraphQL\Error\ValidationError $e) { $validator = $e->getValidator(); + $rules = $validator->getRules(); - $this->assertInstanceOf(Validator::class, $validator); + $this->assertEquals(['max:2'], $rules['email_list']); + $this->assertEquals(['email'], $rules['email_list.0']); + $this->assertEquals(['email'], $rules['email_list.1']); + $this->assertEquals(['email'], $rules['email_list.2']); $messages = $e->getValidatorMessages(); - $this->assertTrue($messages->has('test')); - $this->assertTrue($messages->has('test_with_rules')); - $this->assertTrue($messages->has('test_with_rules_closure')); - $this->assertTrue($messages->has('test_with_rules_input_object.val')); - $this->assertTrue($messages->has('test_with_rules_input_object.nest')); - $this->assertTrue($messages->has('test_with_rules_input_object.list')); + $this->assertEquals('The email list may not be greater than 2 characters.', $messages->first('email_list')); + $this->assertEquals('The email_list.0 must be a valid email address.', $messages->first('email_list.0')); } } /** - * Test custom validation error messages. - * * @test */ - public function testCustomValidationErrorMessages() + public function testRequiredWithRule() { - $class = $this->getFieldClass(); - $field = new $class(); - $rules = $field->getRules(); - $attributes = $field->getAttributes(); try { - $attributes['resolve'](null, [ - 'test_with_rules_input_object' => [ - 'nest' => ['email' => 'invalidTestEmail.com'], - ], - ], [], null); + $this->callResolveWithInput([ + 'required' => 'whatever' + ]); } catch (\Folklore\GraphQL\Error\ValidationError $e) { - $messages = $e->getValidatorMessages(); + $validator = $e->getValidator(); + $rules = $validator->getRules(); - $this->assertEquals($messages->first('test'), 'A test is required.'); - $this->assertEquals($messages->first('test_with_rules_input_object.nest.email'), 'Invalid your email : invalidTestEmail.com'); + $this->assertEquals(['required_with:required'], $rules['required_with']); + + $messages = $e->getValidatorMessages(); + $this->assertEquals('The required with field is required when required is present.', $messages->first('required_with')); } } + + protected function callResolveWithInput($input) + { + $field = $this->getFieldInstance(); + $attributes = $field->getAttributes(); + + $attributes['resolve'](null, $input, [], new \GraphQL\Type\Definition\ResolveInfo([])); + } } diff --git a/tests/Objects/ExampleNestedValidationInputObject.php b/tests/Objects/ExampleChildInputObject.php similarity index 54% rename from tests/Objects/ExampleNestedValidationInputObject.php rename to tests/Objects/ExampleChildInputObject.php index d4a394c9..fa478a64 100644 --- a/tests/Objects/ExampleNestedValidationInputObject.php +++ b/tests/Objects/ExampleChildInputObject.php @@ -1,22 +1,16 @@ 'ExampleNestedValidationInputObject' + 'name' => 'ExampleChildInputObject' ]; - public function type() - { - return Type::listOf(Type::string()); - } - public function fields() { return [ @@ -27,9 +21,4 @@ public function fields() ], ]; } - - public function resolve($root, $args) - { - return ['test']; - } } diff --git a/tests/Objects/ExampleField.php b/tests/Objects/ExampleField.php index 4c1face2..5e0f1c87 100644 --- a/tests/Objects/ExampleField.php +++ b/tests/Objects/ExampleField.php @@ -1,7 +1,7 @@ 'exampleMutation' + ]; + + public function type() + { + return GraphQL::type('Example'); + } + + public function args() + { + return [ + 'required' => [ + 'name' => 'required', + // Define required args through GraphQL types instead of Laravel validation + // This way graphql-php takes care of validating that and the requirements + // show up in the schema. + 'type' => Type::nonNull(Type::string()), + ], + + 'required_with' => [ + 'name' => 'required_with', + 'type' => Type::string(), + ], + + 'email_seperate_rules' => [ + 'name' => 'email_seperate_rules', + 'type' => Type::string(), + // Should be overwritten by those defined in rules() + 'rules' => ['integer'], + ], + + 'email_inline_rules' => [ + 'name' => 'email_inline_rules', + 'type' => Type::string(), + 'rules' => ['email'] + ], + + 'email_closure_rules' => [ + 'name' => 'email_closure_rules', + 'type' => Type::string(), + 'rules' => function () { + return ['email']; + } + ], + + 'email_list' => [ + 'name' => 'email_list', + 'type' => Type::listOf(Type::string()), + 'rules' => ['email'], + ], + + 'email_list_of_lists' => [ + 'name' => 'email_list_of_lists', + 'type' => Type::listOf(Type::listOf(Type::string())), + 'rules' => ['email'], + ], + + 'input_object' => [ + 'name' => 'input_object', + 'type' => GraphQL::type('ExampleParentInputObject'), + ], + ]; + } + + public function resolve($root, $args, $context, ResolveInfo $info) + { + return [ + 'test' => array_get($args, 'test') + ]; + } + + public function rules() + { + return [ + 'email_seperate_rules' => ['email'], + 'email_list' => ['max:2'], + 'required_with' => ['required_with:required'], + ]; + } + + protected function validationErrorMessages($root, $args, $context) + { + $invalidEmail = array_get($args, 'input_object.child.email'); + + return [ + 'email_inline_rules.email' => 'Has to be a valid email.', + 'input_object.child.email.email' => 'Invalid email: ' . $invalidEmail, + ]; + } +} diff --git a/tests/Objects/ExampleParentInputObject.php b/tests/Objects/ExampleParentInputObject.php new file mode 100644 index 00000000..a92698f5 --- /dev/null +++ b/tests/Objects/ExampleParentInputObject.php @@ -0,0 +1,52 @@ + 'ExampleParentInputObject', + ]; + + public function type() + { + return Type::listOf(Type::string()); + } + + public function fields() + { + return [ + + 'alpha' => [ + 'name' => 'alpha', + 'type' => Type::nonNull(Type::string()), + 'rules' => ['alpha'], + ], + + 'child' => [ + 'name' => 'child', + 'type' => GraphQL::type('ExampleChildInputObject'), + ], + + 'child_list' => [ + 'name' => 'child_list', + 'type' => Type::listOf(GraphQL::type('ExampleChildInputObject')), + ], + + // Reference itself. Used in test for avoiding infinite loop when creating validation rules + 'self' => [ + 'name' => 'self', + 'type' => GraphQL::type('ExampleParentInputObject'), + ], + + ]; + } + + public function resolve($root, $args) + { + return ['test']; + } +} diff --git a/tests/Objects/ExampleValidationField.php b/tests/Objects/ExampleValidationField.php index c2edece5..3de80a65 100644 --- a/tests/Objects/ExampleValidationField.php +++ b/tests/Objects/ExampleValidationField.php @@ -1,13 +1,10 @@ 'example_validation' ]; @@ -23,13 +20,8 @@ public function args() 'index' => [ 'name' => 'index', 'type' => Type::int(), - 'rules' => ['required'] + 'rules' => ['integer', 'max:100'] ] ]; } - - public function resolve($root, $args) - { - return ['test']; - } } diff --git a/tests/Objects/ExampleValidationInputObject.php b/tests/Objects/ExampleValidationInputObject.php deleted file mode 100644 index 738eae58..00000000 --- a/tests/Objects/ExampleValidationInputObject.php +++ /dev/null @@ -1,45 +0,0 @@ - 'ExampleValidationInputObject' - ]; - - public function type() - { - return Type::listOf(Type::string()); - } - - public function fields() - { - return [ - 'val' => [ - 'name' => 'val', - 'type' => Type::int(), - 'rules' => ['required'] - ], - 'nest' => [ - 'name' => 'nest', - 'type' => GraphQL::type('ExampleNestedValidationInputObject'), - 'rules' => ['required'] - ], - 'list' => [ - 'name' => 'list', - 'type' => Type::listOf(GraphQL::type('ExampleNestedValidationInputObject')), - 'rules' => ['required'] - ], - ]; - } - - public function resolve($root, $args) - { - return ['test']; - } -} diff --git a/tests/Objects/ExamplesContextQuery.php b/tests/Objects/ExamplesContextQuery.php index c051ec81..df2cc10c 100644 --- a/tests/Objects/ExamplesContextQuery.php +++ b/tests/Objects/ExamplesContextQuery.php @@ -1,7 +1,7 @@ ['name' => 'index', 'type' => Type::int()] + 'index' => [ + 'name' => 'index', + 'type' => Type::int(), + 'rules' => ['integer', 'max:100'] + ] ]; } - public function resolve($root, $args) + public function resolve($root, $args, $context, ResolveInfo $info) { - $data = include(__DIR__.'/data.php'); + $data = include(__DIR__ . '/data.php'); if (isset($args['index'])) { return [ diff --git a/tests/Objects/ExamplesRootQuery.php b/tests/Objects/ExamplesRootQuery.php index 730673af..f12f1a90 100644 --- a/tests/Objects/ExamplesRootQuery.php +++ b/tests/Objects/ExamplesRootQuery.php @@ -1,7 +1,7 @@ 'updateExample' - ]; - - public function type() - { - return GraphQL::type('Example'); - } - - public function rules() - { - return [ - 'test' => ['required'] - ]; - } - - public function args() - { - return [ - 'test' => [ - 'name' => 'test', - 'type' => Type::string() - ], - - 'test_with_rules' => [ - 'name' => 'test', - 'type' => Type::string(), - 'rules' => ['required'] - ], - - 'test_with_rules_closure' => [ - 'name' => 'test', - 'type' => Type::string(), - 'rules' => function () { - return ['required']; - } - ], - ]; - } - - public function resolve($root, $args) - { - return [ - 'test' => array_get($args, 'test') - ]; - } -} diff --git a/tests/Objects/UpdateExampleMutationWithInputType.php b/tests/Objects/UpdateExampleMutationWithInputType.php deleted file mode 100644 index 2f90a185..00000000 --- a/tests/Objects/UpdateExampleMutationWithInputType.php +++ /dev/null @@ -1,71 +0,0 @@ - 'updateExample', - ]; - - public function type() - { - return GraphQL::type('Example'); - } - - public function rules() - { - return [ - 'test' => ['required'], - ]; - } - - public function validationErrorMessages($root, $args, $context) - { - $inavlidEmail = array_get($args, 'test_with_rules_input_object.nest.email'); - - return [ - 'test.required' => 'A test is required.', - 'test_with_rules_input_object.nest.email.email' => 'Invalid your email : '.$inavlidEmail, - ]; - } - - public function args() - { - return [ - 'test' => [ - 'name' => 'test', - 'type' => Type::string(), - ], - - 'test_with_rules' => [ - 'name' => 'test', - 'type' => Type::string(), - 'rules' => ['required'], - ], - - 'test_with_rules_closure' => [ - 'name' => 'test', - 'type' => Type::string(), - 'rules' => function () { - return ['required']; - }, - ], - - 'test_with_rules_input_object' => [ - 'name' => 'test', - 'type' => GraphQL::type('ExampleValidationInputObject'), - 'rules' => ['required'], - ], - ]; - } - - public function resolve($root, $args) - { - return [ - 'test' => array_get($args, 'test'), - ]; - } -} diff --git a/tests/Objects/queries.php b/tests/Objects/queries.php index 0bcebcb2..4554cd5e 100644 --- a/tests/Objects/queries.php +++ b/tests/Objects/queries.php @@ -75,15 +75,15 @@ 'examplesWithValidation' => " query QueryExamplesWithValidation(\$index: Int) { - examples { - test_validation(index: \$index) + examples(index: \$index) { + test } } ", - 'updateExampleCustom' => " - mutation UpdateExampleCustom(\$test: String) { - updateExampleCustom(test: \$test) { + 'exampleMutation' => " + mutation ExampleMutation(\$required: String) { + exampleMutation(required: \$required) { test } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 3ac5406b..7cdddf82 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -30,7 +30,7 @@ protected function getEnvironmentSetUp($app) 'examplesPagination' => ExamplesPaginationQuery::class, ], 'mutation' => [ - 'updateExample' => UpdateExampleMutation::class + 'exampleMutation' => ExampleMutation::class ] ]); @@ -39,12 +39,14 @@ protected function getEnvironmentSetUp($app) 'examplesCustom' => ExamplesQuery::class, ], 'mutation' => [ - 'updateExampleCustom' => UpdateExampleMutation::class + 'exampleMutationCustom' => ExampleMutation::class ] ]); $app['config']->set('graphql.types', [ - 'Example' => ExampleType::class + 'Example' => ExampleType::class, + 'ExampleParentInputObject' => ExampleParentInputObject::class, + 'ExampleChildInputObject' => ExampleChildInputObject::class, ]); }