diff --git a/README.md b/README.md index c74f63a..f065865 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,63 @@ -# laravel-graphql-relay +# laravel-grapql-relay # -## Documentation currently under development +Use Facebook [GraphQL](http://facebook.github.io/graphql/) with [React Relay](https://facebook.github.io/relay/). This package extends graphql-php to work with Laravel and is currently **a work in progress**. You can reference what specifications GraphQL needs to provide to work with Relay in the [documentation](https://facebook.github.io/relay/docs/graphql-relay-specification.html#content). -Use Facebook [GraphQL](http://facebook.github.io/graphql/) with [React Relay](https://facebook.github.io/relay/). This package is used alongside [laravel-graphql](https://github.com/Folkloreatelier/laravel-graphql) and is currently **a work in progress**. You can reference what specifications GraphQL needs to provide to work with Relay in the [documentation](https://facebook.github.io/relay/docs/graphql-relay-specification.html#content). +Although this package no longer depends on [laraval-graphql](https://github.com/Folkloreatelier/laravel-graphql), it laid the foundation for this package which likely wouldn't exist without it. It is also a great alternative if you are using GraphQL w/o support for Relay. -## Installation +Because this package is still in the early stages, breaking changes will occur. We will keep the documentation updated with the current release. Please feel free to contribute, PR are absolutely welcome! + +### Installation ### You must then modify your composer.json file and run composer update to include the latest version of the package in your project. -```json +```php "require": { - "nuwave/laravel-graphql-relay": "0.2.*" + "nuwave/laravel-graphql-relay": "0.3.*" } ``` -Or you can use the ```composer require``` command from your terminal. +Or you can use the composer require command from your terminal. -```json +``` composer require nuwave/laravel-graphql-relay ``` Add the service provider to your ```app/config.php``` file -```php -Nuwave\Relay\ServiceProvider::class +``` +Nuwave\Relay\LaravelServiceProvider::class ``` -Add the Relay facade to your ```app/config.php``` file +Add the Relay & GraphQL facade to your app/config.php file -```php +``` +'GraphQL' => Nuwave\Relay\Facades\GraphQL::class, 'Relay' => Nuwave\Relay\Facades\Relay::class, ``` Publish the configuration file -```php -php artisan vendor:publish --provider="Nuwave\Relay\ServiceProvider" +``` +php artisan vendor:publish --provider="Nuwave\Relay\LaravelServiceProvider" ``` -Add your Mutations, Queries and Types to the ```config/relay.php``` file +Create a ```schema.php``` file and add the path to the config -```php -// Example: - -return [ - 'schema' => function () { - // Added by default - Relay::group(['namespace' => 'Nuwave\\Relay'], function () { - Relay::group(['namespace' => 'Node'], function () { - Relay::query('node', 'NodeQuery'); - Relay::type('node', 'NodeType'); - }); - - Relay::type('pageInfo', 'Types\\PageInfoType'); - }); - - // Your mutations, queries and types - Relay::group(['namespace' => 'App\\Http\\GraphQL'], function () { - Relay::group(['namespace' => 'Mutations'], function () { - Relay::mutation('createUser', 'CreateUserMutation'); - }); - - Relay::group(['namespace' => 'Queries', function () { - Relay::query('userQuery', 'UserQuery'); - }); - - Relay::group(['namespace' => 'Types'], function () { - Relay::type('user', 'UserType'); - Relay::type('event', 'EventType'); - }); - }); - } -]; +``` +// config/relay.php +// ... +'schema' => [ + 'path' => 'Http/schema.php', + 'output' => null, +], ``` -To generate a ```schema.json``` file (used with the [Babel Relay Plugin](https://facebook.github.io/relay/docs/guides-babel-plugin.html#content)) +To generate a ```schema.json``` file (used with the Babel Relay Plugin): -```php +``` php artisan relay:schema ``` -For additional documentation, please read the [Wiki](https://github.com/nuwave/laravel-graphql-relay/wiki/1.-GraphQL-and-Relay) +*You can customize the output path in the ```relay.php``` config file under ```schema.output```* + +For additional documentation, look through the docs folder or read the Wiki. diff --git a/composer.json b/composer.json index 52d36d4..a043d17 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ } }, "require": { - "folklore/graphql": "0.*", - "illuminate/console": "5.*" + "webonyx/graphql-php": "~0.5", + "illuminate/console": "5.*", + "doctrine/dbal": "^2.5" } } diff --git a/config/config.php b/config/config.php index ef90e6e..b05ddf9 100644 --- a/config/config.php +++ b/config/config.php @@ -1,25 +1,43 @@ function () { - Relay::group(['namespace' => 'Nuwave\\Relay'], function () { - Relay::group(['namespace' => 'Node'], function () { - Relay::query('node', 'NodeQuery'); - Relay::type('node', 'NodeType'); - }); - Relay::type('pageInfo', 'Types\\PageInfoType'); - }); + /* + |-------------------------------------------------------------------------- + | Namespace registry + |-------------------------------------------------------------------------- + | + | This package provides a set of commands to make it easy for you to + | create new parts in your GraphQL schema. Change these values to + | match the namespaces you'd like each piece to be created in. + | + */ - // Additional Queries, Mutations and Types... - } + 'namespaces' => [ + 'mutations' => 'App\\GraphQL\\Mutations', + 'queries' => 'App\\GraphQL\\Queries', + 'types' => 'App\\GraphQL\\Types', + 'fields' => 'App\\GraphQL\\Fields', + ], + + /* + |-------------------------------------------------------------------------- + | Schema declaration + |-------------------------------------------------------------------------- + | + | This is a path that points to where your Relay schema is located + | relative to the app path. You should define your entire Relay + | schema in this file. Declare any Relay queries, mutations, + | and types here instead of laravel-graphql config file. + | + */ + + 'schema' => [ + 'path' => null, + 'output' => null, + ], + + 'controller' => 'Nuwave\Relay\Http\Controllers\LaravelController@query', + 'model_path' => 'App\\Models', + 'camel_case' => false, ]; diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 0000000..61197f1 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,52 @@ +## Configuration ## + +When publishing the configuration, the package will create a ```relay.php``` file in your ```config``` folder. + +### Namespaces ### + +```php +'namespaces' => [ + 'mutations' => 'App\\GraphQL\\Mutations', + 'queries' => 'App\\GraphQL\\Queries', + 'types' => 'App\\GraphQL\\Types', + 'fields' => 'App\\GraphQL\\Fields', +], +``` + +This package provides a list of commands that allows you to create Types, Mutations, Queries and Fields. You can specify the namespaces you would like the package to use when generating the files. + +### Schema ### + +```php +'schema' => [ + 'file' => 'Http/GraphQL/schema.php', + 'output' => null, +] +``` + +** File ** + +Set the location of your schema file. (A schema is similar to your routes.php file and defines your Types, Mutations and Queries for GraphQL. Read More) + +** Output ** + +This is the location where your generated ```schema.json``` will be created/updated. (This json file is used by the [Babel Relay Plugin](https://facebook.github.io/relay/docs/guides-babel-plugin.html#content)). + +### Eloquent ### + +```php +'eloquent' => [ + 'path' => 'App\\Models', + 'camel_case' => false +] +``` + +** Path ** + +The package allows you to create Types based off of your Eloquent models. You can use the ```path``` to define the namespace of your models or you can use the full namespace when generating Types from the console (Read More). + +** Camel Case ** + +Camel casing is quite common in javascript, but Laravel's database column naming convention is snake case. If you would like your Eloquent model's generated fields converted to camel case, you may set this to true. + +*This works great with the [Eloquence package](https://github.com/kirkbushell/eloquence).* diff --git a/docs/Overview.md b/docs/Overview.md new file mode 100644 index 0000000..f065865 --- /dev/null +++ b/docs/Overview.md @@ -0,0 +1,63 @@ +# laravel-grapql-relay # + +Use Facebook [GraphQL](http://facebook.github.io/graphql/) with [React Relay](https://facebook.github.io/relay/). This package extends graphql-php to work with Laravel and is currently **a work in progress**. You can reference what specifications GraphQL needs to provide to work with Relay in the [documentation](https://facebook.github.io/relay/docs/graphql-relay-specification.html#content). + +Although this package no longer depends on [laraval-graphql](https://github.com/Folkloreatelier/laravel-graphql), it laid the foundation for this package which likely wouldn't exist without it. It is also a great alternative if you are using GraphQL w/o support for Relay. + +Because this package is still in the early stages, breaking changes will occur. We will keep the documentation updated with the current release. Please feel free to contribute, PR are absolutely welcome! + +### Installation ### + +You must then modify your composer.json file and run composer update to include the latest version of the package in your project. + +```php +"require": { + "nuwave/laravel-graphql-relay": "0.3.*" +} +``` + +Or you can use the composer require command from your terminal. + +``` +composer require nuwave/laravel-graphql-relay +``` + +Add the service provider to your ```app/config.php``` file + +``` +Nuwave\Relay\LaravelServiceProvider::class +``` + +Add the Relay & GraphQL facade to your app/config.php file + +``` +'GraphQL' => Nuwave\Relay\Facades\GraphQL::class, +'Relay' => Nuwave\Relay\Facades\Relay::class, +``` + +Publish the configuration file + +``` +php artisan vendor:publish --provider="Nuwave\Relay\LaravelServiceProvider" +``` + +Create a ```schema.php``` file and add the path to the config + +``` +// config/relay.php +// ... +'schema' => [ + 'path' => 'Http/schema.php', + 'output' => null, +], +``` + +To generate a ```schema.json``` file (used with the Babel Relay Plugin): + +``` +php artisan relay:schema +``` + +*You can customize the output path in the ```relay.php``` config file under ```schema.output```* + +For additional documentation, look through the docs folder or read the Wiki. diff --git a/docs/Relay.md b/docs/Relay.md new file mode 100644 index 0000000..78eeefc --- /dev/null +++ b/docs/Relay.md @@ -0,0 +1,130 @@ +## Object Identification + +Facebook Relay [Documentation](https://facebook.github.io/relay/docs/graphql-object-identification.html#content) + +Facebook GraphQL [Spec](https://facebook.github.io/relay/graphql/objectidentification.htm) + +To implement a GraphQL Type that adheres to the Relay Object Identification spec, make sure your type extends ```Nuwave\Relay\Support\Definition\RelayType``` and implements the ```resolveById``` and ```relayFields``` methods. + +Example: + +```php + 'Customer', + 'description' => 'A Customer model.', + ]; + + /** + * Get customer by id. + * + * When the root 'node' query is called, it will use this method + * to resolve the type by providing the id. + * + * @param string $id + * @return Customer + */ + public function resolveById($id) + { + return Customer::find($id); + } + + /** + * Available fields of Type. + * + * @return array + */ + public function relayFields() + { + return [ + // Note: You may omit the id field as it will be overwritten to adhere to + // the NodeInterface + 'id' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => 'ID of the customer.' + ], + // ... + ]; + } +} +``` + +## Connections + +Facebook Relay [Documentation](https://facebook.github.io/relay/docs/graphql-connections.html#content) + +Facebook GraphQL [Spec](https://facebook.github.io/relay/graphql/connections.htm) + +To create a connection, simply use ```GraphQL::connection('typeName', Closure)```. We need to pass back an object that [implements the ```Illuminate\Contract\Pagination\LengthAwarePaginator``` interface](http://laravel.com/api/5.1/Illuminate/Contracts/Pagination/LengthAwarePaginator.html). In this example, we'll add it to our CustomerType we created in the Object Identification section. + +*(You can omit the resolve function if you are working with an Eloquent model. The package will use the same code as show below to resolve the connection.)* + +Example: + +```php + GraphQL::connection('order', function ($customer, array $args, ResolveInfo $info) { + // Note: This is just an example. This type of resolve functionality may not make sense for your + // application so just use what works best for you. However, you will need to pass back an object + // that implements the LengthAwarePaginator as mentioned above in order for it to work with the + // Relay connection spec. + $orders = $customer->orders; + + if (isset($args['first'])) { + $total = $orders->count(); + $first = $args['first']; + $after = $this->decodeCursor($args); + $currentPage = $first && $after ? floor(($first + $after) / $first) : 1; + + return new Paginator( + $orders->slice($after)->take($first), + $total, + $first, + $currentPage + ); + } + + return new Paginator( + $orders, + $orders->count(), + $orders->count() + ); + }), + // Alternatively, you can let the package resolve this connection for you + // by passing the name of the relationship. + 'orders' => GraphQL::connection('order', 'orders') + ]; + } +} +``` diff --git a/docs/Schema.md b/docs/Schema.md new file mode 100644 index 0000000..e239e11 --- /dev/null +++ b/docs/Schema.md @@ -0,0 +1,334 @@ +## Schema + +### Types + +Creating a Type: + +``` +php artisan make:relay:type UserType +``` + +```php + 'User', + 'description' => 'A user of the application.', + ]; + + /** + * Get user by id. + * + * @param string $id + * @return User + */ + public function resolveById($id) + { + return User::find($id); + } + + /** + * Available fields of Type. + * + * @return array + */ + public function relayFields() + { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => 'The primary id of the user.' + ], + 'name' => [ + 'type' => Type::string(), + 'description' => 'Full name of user.' + ], + 'email' => [ + 'type' => Type::string(), + 'description' => 'Email address of user.' + ] + // ... + ] + } +} +``` + +### Queries + +Create a Query: + +```bash +php artisan make:relay:query UserQuery +``` + +```php + [ + 'type' => Type::nonNull(Type::string()), + ] + ]; + } + + /** + * Resolve the query. + * + * @param mixed $root + * @param array $args + * @return mixed + */ + public function resolve($root, array $args) + { + return User::find($args['id']); + } +} + +``` + +### Mutations + +Create a mutation: + +```bash +php artisan make:relay:mutation +``` + +```php + [ + 'type' => Type::string(), + 'rules' => ['required'] + ], + 'password' => [ + 'type' => Type::string() + ] + ]; + } + + /** + * Rules for mutation. + * + * Note: You can add your rules here or define + * them in the inputFields + * + * @return array + */ + public function rules() + { + return [ + 'password' => ['required', 'min:15'] + ]; + } + + /** + * Fields that will be sent back to client. + * + * @return array + */ + protected function outputFields() + { + return [ + 'user' => [ + 'type' => GraphQL::type('user'), + 'resolve' => function (User $user) { + return $user; + } + ] + ]; + } + + /** + * Perform data mutation. + * + * @param array $input + * @param ResolveInfo $info + * @return array + */ + protected function mutateAndGetPayload(array $input, ResolveInfo $info) + { + $user = User::find($input['id']); + $user->password = \Hash::make($input['password']); + $user->save(); + + return $user; + } +} + +``` + +### Custom Fields + +Create a custom field: + +```bash +php artisan relay:make:field AvatarField +``` + +```php + 'Avatar of user.' + ]; + + /** + * The return type of the field. + * + * @return Type + */ + public function type() + { + return Type::string(); + } + + /** + * Available field arguments. + * + * @return array + */ + public function args() + { + return [ + 'width' => [ + 'type' => Type::int(), + 'description' => 'The width of the picture' + ], + 'height' => [ + 'type' => Type::int(), + 'description' => 'The height of the picture' + ] + ]; + } + + /** + * Resolve the field. + * + * @param mixed $root + * @param array $args + * @return mixed + */ + public function resolve($root, array $args) + { + $width = isset($args['width']) ? $args['width'] : 100; + $height = isset($args['height']) ? $args['height'] : 100; + + return 'http://placehold.it/'.$root->id.'/'.$width.'x'.$height; + } +} +``` + +### Schema File + +The ```schema.php``` file you create is similar to Laravel's ```routes.php``` file. It used to declare your Types, Mutations and Queries to be used by GraphQL. Similar to routes, you can group your schema by namespace as well as add middleware to your Queries and Mutations. + +*Be sure your file name is located in the ```relay.php``` config file* + +```php +// config/relay.php + +'schema' => [ + 'path' => 'Http/schema.php', + 'output' => null +], +``` + +```php +// app/Http/schema.php + +Relay::group(['namespace' => 'App\\Http\\GraphQL', 'middleware' => 'auth'], function () { + Relay::group(['namespace' => 'Mutations'], function () { + Relay::mutation('createUser', 'CreateUserMutation'); + }); + + Relay::group(['namespace' => 'Queries'], function () { + Relay::query('userQuery', 'UserQuery'); + }); + + Relay::group(['namespace' => 'Types'], function () { + Relay::type('user', 'UserType'); + }); +}); +``` diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php new file mode 100644 index 0000000..31863c3 --- /dev/null +++ b/src/Commands/CacheCommand.php @@ -0,0 +1,57 @@ +cache = $cache; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $this->cache->flush(); + + app('graphql')->schema(); + + $this->info('Eloquent Types successfully cached.'); + } +} diff --git a/src/Commands/FieldMakeCommand.php b/src/Commands/FieldMakeCommand.php new file mode 100644 index 0000000..0fe5163 --- /dev/null +++ b/src/Commands/FieldMakeCommand.php @@ -0,0 +1,50 @@ +option('model')) { + $this->setViewPath(); + $stub = $this->getEloquentStub($model); + } else { + $stub = $this->files->get($this->getStub()); + } + + return $this->replaceNamespace($stub, $name)->replaceClass($stub, $name); + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['model', null, InputOption::VALUE_OPTIONAL, 'Generate a Eloquent GraphQL type.'], + ]; + } + + /** + * Set config view paths. + * + * @return void + */ + protected function setViewPath() + { + $paths = config('view.paths'); + $paths[] = realpath(__DIR__.'/stubs'); + + config(['view.paths' => $paths]); + } + + /** + * Generate stub from eloquent type. + * + * @param string $model + * @return string + */ + protected function getEloquentStub($model) + { + $shortName = $model; + $rootNamespace = $this->laravel->getNamespace(); + + if (starts_with($model, $rootNamespace)) { + $shortName = (new ReflectionClass($model))->getShortName(); + } else { + $model = config('relay.model_path') . "\\" . $model; + } + + $fields = $this->getTypeFields($model); + + return "render(); + } + + /** + * Generate fields for type. + * + * @param string $class + * @return array + */ + protected function getTypeFields($class) + { + $model = app($class); + + return (new EloquentType($model))->rawFields(); + } +} diff --git a/src/Commands/stubs/eloquent.blade.php b/src/Commands/stubs/eloquent.blade.php new file mode 100644 index 0000000..defebba --- /dev/null +++ b/src/Commands/stubs/eloquent.blade.php @@ -0,0 +1,51 @@ +namespace DummyNamespace; + +use GraphQL; +use GraphQL\Type\Definition\Type; +use Nuwave\Relay\Support\Definition\RelayType; +use GraphQL\Type\Definition\ResolveInfo; +use {{ $model }}; + +class DummyClass extends RelayType +{ + /** + * Attributes of Type. + * + * @var array + */ + protected $attributes = [ + 'name' => '{{ $shortName }}', + 'description' => '', + ]; + + /** + * Get customer by id. + * + * When the root 'node' query is called, it will use this method + * to resolve the type by providing the id. + * + * @param string $id + * @return User + */ + public function resolveById($id) + { + return {{ $shortName }}::findOrFail($id); + } + + /** + * Available fields of Type. + * + * @return array + */ + public function relayFields() + { + return [ +@foreach($fields as $key => $field) + '{{ $key }}' => [ + 'type' => {{ $field['type'] }}, + 'description' => '{{ $field['description'] }}', + ], +@endforeach + ]; + } +} diff --git a/src/Commands/stubs/field.stub b/src/Commands/stubs/field.stub new file mode 100644 index 0000000..3203f1f --- /dev/null +++ b/src/Commands/stubs/field.stub @@ -0,0 +1,52 @@ + '' + ]; + + /** + * The return type of the field. + * + * @return Type + */ + public function type() + { + // return Type::string(); + } + + /** + * Available field arguments. + * + * @return array + */ + public function args() + { + return []; + } + + /** + * Resolve the field. + * + * @param mixed $root + * @param array $args + * @return mixed + */ + public function resolve($root, array $args) + { + // TODO: Resolve field + } +} diff --git a/src/Commands/stubs/mutation.stub b/src/Commands/stubs/mutation.stub new file mode 100644 index 0000000..90d0a83 --- /dev/null +++ b/src/Commands/stubs/mutation.stub @@ -0,0 +1,66 @@ + '', + 'description' => '', + ]; + + /** + * Get model by id. + * + * When the root 'node' query is called, it will use this method + * to resolve the type by providing the id. + * + * @param string $id + * @return \Eloquence\Database\Model + */ + public function resolveById($id) + { + // return Model::find($id); + } + + /** + * Available fields of Type. + * + * @return array + */ + public function relayFields() + { + return []; + } + + /** + * List of related connections. + * + * @return array + */ + public function connections() + { + return []; + } +} diff --git a/src/Facades/GraphQL.php b/src/Facades/GraphQL.php new file mode 100644 index 0000000..87ce1c5 --- /dev/null +++ b/src/Facades/GraphQL.php @@ -0,0 +1,18 @@ +setupQuery($request); + } + + /** + * Execute GraphQL query. + * + * @param Request $request + * @return Response + */ + public function query(Request $request) + { + $query = $request->get('query'); + $params = $request->get('variables'); + + if (is_string($params)) { + $params = json_decode($params, true); + } + + return app('graphql')->query($query, $params); + } +} diff --git a/src/Controllers/RelayController.php b/src/Http/Controllers/LumenController.php similarity index 73% rename from src/Controllers/RelayController.php rename to src/Http/Controllers/LumenController.php index 44c8a02..621b3df 100644 --- a/src/Controllers/RelayController.php +++ b/src/Http/Controllers/LumenController.php @@ -1,14 +1,14 @@ get('query'); + $params = $request->get('variables'); if (is_string($params)) { diff --git a/src/Http/routes.php b/src/Http/routes.php new file mode 100644 index 0000000..32d3716 --- /dev/null +++ b/src/Http/routes.php @@ -0,0 +1,5 @@ + 'graphql', 'uses' => $controller]); diff --git a/src/LaravelServiceProvider.php b/src/LaravelServiceProvider.php new file mode 100644 index 0000000..57fb421 --- /dev/null +++ b/src/LaravelServiceProvider.php @@ -0,0 +1,126 @@ +publishes([__DIR__ . '/../config/config.php' => config_path('relay.php')]); + + $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'relay'); + + $this->registerSchema(); + + if (config('relay.controller')) { + include __DIR__.'/Http/routes.php'; + } + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->commands([ + SchemaCommand::class, + CacheCommand::class, + MutationMakeCommand::class, + FieldMakeCommand::class, + QueryMakeCommand::class, + TypeMakeCommand::class, + ]); + + $this->app->singleton('graphql', function ($app) { + return new GraphQL($app); + }); + + $this->app->singleton('relay', function ($app) { + return new SchemaContainer(new Parser); + }); + } + + /** + * Register schema mutations and queries. + * + * @return void + */ + protected function registerSchema() + { + if (config('relay.schema.path')) { + require_once app_path(config('relay.schema.path')); + } + + $this->registerRelayTypes(); + + $this->setGraphQLConfig(); + + $this->initializeTypes(); + } + + /** + * Register the default relay types in the schema. + * + * @return void + */ + protected function registerRelayTypes() + { + $relay = $this->app['relay']; + + $relay->group(['namespace' => 'Nuwave\\Relay'], function () use ($relay) { + $relay->query('node', 'Node\\NodeQuery'); + $relay->type('node', 'Node\\NodeType'); + $relay->type('pageInfo', 'Support\\Definition\\PageInfoType'); + }); + } + + /** + * Set GraphQL configuration variables. + * + * @return void + */ + protected function setGraphQLConfig() + { + $relay = $this->app['relay']; + + $mutations = config('relay.schema.mutations', []); + $queries = config('relay.schema.queries', []); + $types = config('relay.schema.types', []); + + config([ + 'relay.schema.mutations' => array_merge($mutations, $relay->getMutations()->config()), + 'relay.schema.queries' => array_merge($queries, $relay->getQueries()->config()), + 'relay.schema.types' => array_merge($types, $relay->getTypes()->config()) + ]); + } + + /** + * Initialize GraphQL types array. + * + * @return void + */ + protected function initializeTypes() + { + foreach (config('relay.schema.types') as $name => $type) { + $this->app['graphql']->addType($type, $name); + } + } +} diff --git a/src/LumenServiceProvider.php b/src/LumenServiceProvider.php new file mode 100644 index 0000000..edcbd01 --- /dev/null +++ b/src/LumenServiceProvider.php @@ -0,0 +1,120 @@ +mergeConfigFrom(__DIR__ . '/../config/config.php', 'relay'); + + $this->registerSchema(); + } + + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->commands([ + SchemaCommand::class, + CacheCommand::class, + MutationMakeCommand::class, + FieldMakeCommand::class, + QueryMakeCommand::class, + TypeMakeCommand::class, + ]); + + $this->app->singleton('graphql', function ($app) { + return new GraphQL($app); + }); + + $this->app->singleton('relay', function ($app) { + return new SchemaContainer(new Parser); + }); + } + + /** + * Register schema mutations and queries. + * + * @return void + */ + protected function registerSchema() + { + $this->registerRelayTypes(); + + if (config('relay.schema.path')) { + require_once __DIR__ . '/../../../../app/' . config('relay.schema.path'); + } + + $this->setGraphQLConfig(); + + $this->initializeTypes(); + } + + /** + * Register the default relay types in the schema. + * + * @return void + */ + protected function registerRelayTypes() + { + $relay = $this->app['relay']; + + $relay->group(['namespace' => 'Nuwave\\Relay'], function () use ($relay) { + $relay->query('node', 'Node\\NodeQuery'); + $relay->type('node', 'Node\\NodeType'); + $relay->type('pageInfo', 'Support\\Definition\\PageInfoType'); + }); + } + + /** + * Set GraphQL configuration variables. + * + * @return void + */ + protected function setGraphQLConfig() + { + $relay = $this->app['relay']; + + $mutations = config('relay.schema.mutations', []); + $queries = config('relay.schema.queries', []); + $types = config('relay.schema.types', []); + + config([ + 'relay.schema.mutations' => array_merge($mutations, $relay->getMutations()->config()), + 'relay.schema.queries' => array_merge($queries, $relay->getQueries()->config()), + 'relay.schema.types' => array_merge($types, $relay->getTypes()->config()) + ]); + } + + /** + * Initialize GraphQL types array. + * + * @return void + */ + protected function initializeTypes() + { + foreach (config('relay.schema.types') as $name => $type) { + $this->app['graphql']->addType($type, $name); + } + } +} diff --git a/src/Node/NodeQuery.php b/src/Node/NodeQuery.php index 3992b27..e1d2ee3 100644 --- a/src/Node/NodeQuery.php +++ b/src/Node/NodeQuery.php @@ -3,12 +3,12 @@ namespace Nuwave\Relay\Node; use GraphQL; -use Nuwave\Relay\GlobalIdTrait; +use Nuwave\Relay\Traits\GlobalIdTrait; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\ResolveInfo; -use Folklore\GraphQL\Support\Query; +use Nuwave\Relay\Support\Definition\GraphQLQuery; -class NodeQuery extends Query +class NodeQuery extends GraphQLQuery { use GlobalIdTrait; @@ -45,28 +45,15 @@ public function args() * @return Illuminate\Database\Eloquent\Model|array */ public function resolve($root, array $args, ResolveInfo $info) - { - return $this->getModel($args); - } - - /** - * Get associated model. - * - * @param array $args - * @return Illuminate\Database\Eloquent\Model - */ - protected function getModel(array $args) { // Here, we decode the base64 id and get the id of the type // as well as the type's name. - // list($typeClass, $id) = $this->decodeGlobalId($args['id']); - // Types must be registered in the graphql.php config file. - // - foreach (config('graphql.types') as $type => $class) { + foreach (config('relay.schema.types') as $type => $class) { if ($typeClass == $class) { $objectType = app($typeClass); + $model = $objectType->resolveById($id); if (is_array($model)) { diff --git a/src/Node/NodeType.php b/src/Node/NodeType.php index 997dd66..8ae5721 100644 --- a/src/Node/NodeType.php +++ b/src/Node/NodeType.php @@ -4,22 +4,19 @@ use GraphQL; use GraphQL\Type\Definition\Type; -use Folklore\GraphQL\Support\InterfaceType; +use Nuwave\Relay\Support\Definition\GraphQLInterface; -class NodeType extends InterfaceType +class NodeType extends GraphQLInterface { /** * Interface attributes. * * @var array */ - public function attributes() - { - return [ - 'name' => 'Node', - 'description' => 'An object with an ID.' - ]; - } + protected $attributes = [ + 'name' => 'Node', + 'description' => 'An object with an ID.' + ]; /** * Available fields on type. diff --git a/src/Schema/Connection.php b/src/Schema/Connection.php new file mode 100644 index 0000000..1763086 --- /dev/null +++ b/src/Schema/Connection.php @@ -0,0 +1,77 @@ +arguments); + } + + /** + * Set arguments of selection. + * + * @param ASTField $field + */ + public function setArguments(ASTField $field) + { + if ($field->arguments) { + foreach ($field->arguments as $argument) { + $this->arguments[$argument->name->value] = $argument->value->value; + } + } + } + + /** + * Set connection path. + * + * @param string $path + */ + public function setPath($path = '') + { + $this->path = $path; + } +} diff --git a/src/Schema/GraphQL.php b/src/Schema/GraphQL.php new file mode 100644 index 0000000..396724d --- /dev/null +++ b/src/Schema/GraphQL.php @@ -0,0 +1,444 @@ +app = $app; + + $this->types = collect(); + $this->queries = collect(); + $this->mutations = collect(); + $this->typeInstances = collect(); + $this->connectionInstances = collect(); + $this->edgeInstances = collect(); + } + + /** + * Execute GraphQL query. + * + * @param string $query + * @param array $variables + * @return array + */ + public function query($query, $variables = []) + { + $result = $this->queryAndReturnResult($query, $variables); + + if (!empty($result->errors)) { + return [ + 'data' => $result->data, + 'errors' => array_map([$this, 'formatError'], $result->errors) + ]; + } + + return ['data' => $result->data]; + } + + /** + * Execute GraphQL query. + * + * @param string $query + * @param array $variables + * @return array + */ + public function queryAndReturnResult($query, $variables = []) + { + return GraphQLBase::executeAndReturnResult($this->schema(), $query, null, $variables); + } + + /** + * Generate GraphQL Schema. + * + * @return \GraphQL\Schema + */ + public function schema() + { + $schema = config('relay.schema'); + + $this->types->each(function ($type, $key) { + $this->type($key); + }); + + $queries = $this->queries->merge(array_get($schema, 'queries', [])); + $mutations = $this->mutations->merge(array_get($schema, 'mutations', [])); + + $queryTypes = $this->generateType($queries, ['name' => 'Query']); + $mutationTypes = $this->generateType($mutations, ['name' => 'Mutation']); + + return new Schema($queryTypes, $mutationTypes); + } + + /** + * Generate type from collection of fields. + * + * @param Collection $fields + * @param array $options + * @return \GraphQL\Type\Definition\ObjectType + */ + public function generateType(Collection $fields, $options = []) + { + $typeFields = $fields->transform(function ($field) { + if (is_string($field)) { + return app($field)->toArray(); + } + + return $field; + })->toArray(); + + return new ObjectType(array_merge(['fields' => $typeFields], $options)); + } + + /** + * Add mutation to collection. + * + * @param string $name + * @param mixed $mutator + */ + public function addMutation($name, $mutator) + { + $this->mutations->put($name, $mutator); + } + + /** + * Add query to collection. + * + * @param string $name + * @param mixed $query + */ + public function addQuery($name, $query) + { + $this->queries->put($name, $query); + } + + /** + * Add type to collection. + * + * @param mixed $class + * @param string|null $name + */ + public function addType($class, $name = null) + { + if (!$name) { + $type = is_object($class) ? $class : app($class); + $name = $type->name; + } + + $this->types->put($name, $class); + } + + /** + * Get instance of type. + * + * @param string $name + * @param boolean $fresh + * @return mixed + */ + public function type($name, $fresh = false) + { + $this->checkType($name); + + if (!$fresh && $this->typeInstances->has($name)) { + return $this->typeInstances->get($name); + } + + $type = $this->types->get($name); + if (!is_object($type)) { + $type = app($type); + } + + $instance = $type instanceof Model ? (new EloquentType($type, $name))->toType() : $type->toType(); + + $this->typeInstances->put($name, $instance); + + if ($type->interfaces) { + InterfaceType::addImplementationToInterfaces($instance); + } + + return $instance; + } + + /** + * Get if type is registered. + * + * @param string $name + * @return boolean + */ + public function hasType($name) + { + return $this->typeInstances->has($name); + } + + /** + * Get registered type. + * + * @param string $name + * @return \GraphQL\Type\Definition\OutputType + */ + public function getType($name) + { + return $this->typeInstances->get($name); + } + + /** + * Get instance of connection type. + * + * @param string $name + * @param Closure|string|null $resolve + * @param boolean $fresh + * @return mixed + */ + public function connection($name, $resolve = null, $fresh = false) + { + $this->checkType($name); + + if ($resolve && !$resolve instanceof Closure) { + $resolve = function ($root, array $args, ResolveInfo $info) use ($resolve) { + return $this->resolveConnection($root, $args, $info, $resolve); + }; + } + + if (!$fresh && $this->connectionInstances->has($name)) { + $field = $this->connectionInstances->get($name); + $field['resolve'] = $resolve; + + return $field; + } + + $field = $this->connectionField($name, $resolve); + + $this->connectionInstances->put($name, $field); + + return $field; + } + + /** + * Get instance of edge type. + * + * @param string $name + * @return \GraphQL\Type\Definition\ObjectType|null + */ + public function edge($name) + { + $this->checkType($name); + + return $this->edgeInstances->get($name); + } + + /** + * Format error for output. + * + * @param Error $e + * @return array + */ + public function formatError(Error $e) + { + $error = ['message' => $e->getMessage()]; + + $locations = $e->getLocations(); + if (!empty($locations)) { + $error['locations'] = array_map(function ($location) { + return $location->toArray(); + }, $locations); + } + + $previous = $e->getPrevious(); + if ($previous && $previous instanceof ValidationError) { + $error['validation'] = $previous->getValidatorMessages(); + } + + return $error; + } + + /** + * Check if type is registered. + * + * @param string $name + * @return void + */ + protected function checkType($name) + { + if (!$this->types->has($name)) { + throw new \Exception("Type [{$name}] not found."); + } + } + + /** + * Generate connection field. + * + * @param string $name + * @param Closure|null $resolve + * @return array + */ + public function connectionField($name, $resolve = null) + { + $type = new RelayConnectionType(); + $connectionName = (!preg_match('/Connection$/', $name)) ? $name.'Connection' : $name; + + $type->setName(studly_case($connectionName)); + $type->setEdgeType($name); + $instance = $type->toType(); + $this->addEdge($instance, $name); + + $field = [ + 'args' => RelayConnectionType::connectionArgs(), + 'type' => $instance, + 'resolve' => $resolve + ]; + + if ($type->interfaces) { + InterfaceType::addImplementationToInterfaces($instance); + } + + return $field; + } + + /** + * Add edge instance. + * + * @param ObjectType $type + * @param string $name + * @return void + */ + public function addEdge(ObjectType $type, $name) + { + if ($edges = $type->getField('edges')) { + $type = $edges->getType()->getWrappedType(); + + $this->edgeInstances->put($name, $type); + } + } + + /** + * Auto-resolve connection. + * + * @param mixed $root + * @param array $args + * @param ResolveInfo $info + * @param string $name + * @return mixed + */ + public function resolveConnection($root, array $args, ResolveInfo $info, $name = '') + { + return $this->getConnectionResolver()->resolve($root, $args, $info, $name); + } + + /** + * Set instance of connection resolver. + * + * @param ConnectionResolver $resolver + */ + public function setConnectionResolver(ConnectionResolver $resolver) + { + $this->connectionResolver = $resolver; + } + + /** + * Get instance of connection resolver. + * + * @return ConnectionResolver + */ + public function getConnectionResolver() + { + return $this->connectionResolver ?: app(ConnectionResolver::class); + } + + /** + * Get instance of cache store. + * + * @return \Nuwave\Relay\Support\Cache\FileStore + */ + public function cache() + { + return $this->cache ?: app(\Nuwave\Relay\Support\Cache\FileStore::class); + } + + /** + * Set instance of Cache store. + * + * @param \Nuwave\Relay\Support\Cache\FileStore + */ + public function setCache(FileStore $cache) + { + $this->cache = $cache; + } +} diff --git a/src/Schema/Parser.php b/src/Schema/Parser.php new file mode 100644 index 0000000..0a62d34 --- /dev/null +++ b/src/Schema/Parser.php @@ -0,0 +1,131 @@ +initialize(); + + $this->parseFields($selectionSet, $root); + + return $this->connections; + } + + /** + * Set the selection set. + * + * @return void + */ + public function initialize() + { + $this->depth = 0; + $this->path = []; + $this->connections = []; + } + + /** + * Determine if field has selection set. + * + * @param Field $field + * @return boolean + */ + protected function hasChildren($field) + { + return $this->isField($field) && isset($field->selectionSet) && !empty($field->selectionSet->selections); + } + + /** + * Determine if name is a relay edge. + * + * @param string $name + * @return boolean + */ + protected function isEdge($name) + { + return in_array($name, $this->relayEdges); + } + + /** + * Parse arguments. + * + * @param array $selectionSet + * @param string $root + * @return void + */ + protected function parseFields(array $selectionSet = [], $root = '') + { + foreach ($selectionSet as $field) { + if ($this->hasChildren($field)) { + $name = $field->name->value;; + + if (!$this->isEdge($name)) { + $this->path[] = $name; + + $connection = new Connection; + $connection->name = $name; + $connection->root = $root; + $connection->path = implode('.', $this->path); + $connection->depth = count($this->path); + $connection->setArguments($field); + + $this->connections[] = $connection; + } + + $this->parseFields($field->selectionSet->selections, $root); + } + } + + array_pop($this->path); + } +} diff --git a/src/Schema/SchemaContainer.php b/src/Schema/SchemaContainer.php index 8a5a5c1..669735c 100644 --- a/src/Schema/SchemaContainer.php +++ b/src/Schema/SchemaContainer.php @@ -3,8 +3,9 @@ namespace Nuwave\Relay\Schema; use Closure; +use GraphQL\Language\Parser as GraphQLParser; +use GraphQL\Language\Source; use Nuwave\Relay\Schema\FieldCollection as Collection; -use Nuwave\Relay\Schema\Field; class SchemaContainer { @@ -43,23 +44,158 @@ class SchemaContainer */ protected $namespace = ''; + /** + * Schema parser. + * + * @var Parser + */ + public $parser; + + /** + * Middleware to be applied to query. + * + * @var array + */ + public $middleware = []; + + /** + * Connections present in query. + * + * @var array + */ + public $connections = []; + /** * Create new instance of Mutation container. * - * @return void + * @param Parser $parser */ - public function __construct() + public function __construct(Parser $parser) { + $this->parser = $parser; + $this->mutations = new Collection; $this->queries = new Collection; $this->types = new Collection; } + /** + * Set up the graphql request. + * + * @param $query string + * @return void + */ + public function setupRequest($query = 'GraphGL request', $operation = 'query') + { + $source = new Source($query); + $ast = GraphQLParser::parse($source); + + if (isset($ast->definitions[0])) { + $d = $ast->definitions[0]; + $operation = $d->operation ?: 'query'; + $selectionSet = $d->selectionSet->selections; + + $this->parseSelections($selectionSet, $operation); + } + } + + /** + * Check to see if field is a parent. + * + * @param string $name + * @return boolean + */ + public function isParent($name) + { + foreach ($this->connections as $connection) { + if ($this->hasPath($connection, $name)) { + return true; + } + } + + return false; + } + + /** + * Get list of connections in query that belong + * to parent. + * + * @param string $parent + * @param array $connections + * @return array + */ + public function connectionsInRequest($parent, array $connections) + { + $queryConnections = []; + + foreach ($this->connections as $connection) { + if ($this->hasPath($connection, $parent) && isset($connections[$connection->name])) { + $queryConnections[] = $connections[$connection->name]; + } + } + + return $queryConnections; + } + + /** + * Get arguments of connection. + * + * @param string $name + * @return array + */ + public function connectionArguments($name) + { + $connection = array_first($this->connections, function ($key, $connection) use ($name) { + return $connection->name == $name; + }); + + if ($connection) { + return $connection->arguments; + } + + return []; + } + + /** + * Determine if connection has parent in it's path. + * + * @param Connection $connection + * @param string $parent + * @return boolean + */ + protected function hasPath(Connection $connection, $parent) + { + return preg_match("/{$parent}./", $connection->path); + } + + /** + * Add connection to collection. + * + * @param string $name + * @param string $namespace + * @return Field + */ + public function connection($name, $namespace) + { + $edgeType = $this->createField($name.'Edge', $namespace); + + $this->types->push($edgeType); + + $connectionType = $this->createField($name.'Connection', $namespace); + + $this->types->push($connectionType); + + return [ + 'connectionType' => $connectionType, + 'edgeType' => $edgeType, + ]; + } + /** * Add mutation to collection. * * @param string $name - * @param array $options + * @param array $namespace * @return Field */ public function mutation($name, $namespace) @@ -75,7 +211,7 @@ public function mutation($name, $namespace) * Add query to collection. * * @param string $name - * @param array $options + * @param array $namespace * @return Field */ public function query($name, $namespace) @@ -103,39 +239,10 @@ public function type($name, $namespace) return $type; } - /** - * Get class name. - * - * @param string $namespace - * @return string - */ - protected function getClassName($namespace) - { - return empty(trim($this->namespace)) ? $namespace : trim($this->namespace, '\\') . '\\' . $namespace; - } - - /** - * Get field and attach necessary middleware. - * - * @param string $name - * @param string $namespace - * @return Field - */ - protected function createField($name, $namespace) - { - $field = new Field($name, $this->getClassName($namespace)); - - if ($this->hasMiddlewareStack()) { - $field->addMiddleware($this->middlewareStack); - } - - return $field; - } - /** * Group child elements. * - * @param array $middleware + * @param array $attributes * @param Closure $callback * @return void */ @@ -258,6 +365,94 @@ public function findType($name) return $this->getTypes()->pull($name); } + /** + * Get the middlware for the query. + * + * @return array + */ + public function middleware() + { + return $this->middleware; + } + + /** + * Get connections for the query. + * + * @return \Illuminate\Support\Collection + */ + public function connections() + { + return collect($this->connections); + } + + /** + * Get connection paths to eager load. + * + * @return array + */ + public function eagerLoad() + { + return $this->connections()->pluck('path')->toArray(); + } + + /** + * Initialize schema. + * + * @param array $selectionSet + * @return void + */ + protected function parseSelections(array $selectionSet = [], $operation = '') + { + foreach ($selectionSet as $selection) { + if ($this->parser->isField($selection)) { + $schema = $this->find($selection->name->value, $operation); + + if (isset($schema['middleware']) && !empty($schema['middleware'])) { + $this->middleware = array_merge($this->middleware, $schema['middleware']); + } + + if (isset($selection->selectionSet) && !empty($selection->selectionSet->selections)) { + $this->connections = array_merge( + $this->connections, + $this->parser->getConnections( + $selection->selectionSet->selections, + $selection->name->value + ) + ); + } + } + } + } + + /** + * Get class name. + * + * @param string $namespace + * @return string + */ + protected function getClassName($namespace) + { + return empty(trim($this->namespace)) ? $namespace : trim($this->namespace, '\\') . '\\' . $namespace; + } + + /** + * Get field and attach necessary middleware. + * + * @param string $name + * @param string $namespace + * @return Field + */ + protected function createField($name, $namespace) + { + $field = new Field($name, $this->getClassName($namespace)); + + if ($this->hasMiddlewareStack()) { + $field->addMiddleware($this->middlewareStack); + } + + return $field; + } + /** * Check if middleware stack is empty. * diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php deleted file mode 100644 index 24e6278..0000000 --- a/src/ServiceProvider.php +++ /dev/null @@ -1,73 +0,0 @@ -publishes([__DIR__ . '/../config/config.php' => config_path('relay.php')]); - $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'relay'); - - $this->registerSchema(); - $this->setConfig(); - } - - /** - * Register any application services. - * - * @return void - */ - public function register() - { - $this->commands([ - \Nuwave\Relay\Commands\SchemaCommand::class, - ]); - - $this->app->singleton('relay', function ($app) { - return new SchemaContainer; - }); - - $this->app->alias('relay', SchemaContainer::class); - } - - /** - * Register schema mutations and queries. - * - * @return void - */ - protected function registerSchema() - { - $register = config('relay.schema'); - - $register(); - } - - /** - * Set configuration variables. - * - * @return void - */ - protected function setConfig() - { - $schema = $this->app['relay']; - - $mutations = config('graphql.schema.mutation', []); - $queries = config('graphql.schema.query', []); - $types = config('graphql.types', []); - - config([ - 'graphql.schema.mutation' => array_merge($mutations, $schema->getMutations()->config()), - 'graphql.schema.query' => array_merge($queries, $schema->getQueries()->config()), - 'graphql.types' => array_merge($types, $schema->getTypes()->config()) - ]); - } -} diff --git a/src/Support/Cache/FileStore.php b/src/Support/Cache/FileStore.php new file mode 100644 index 0000000..9f8cde9 --- /dev/null +++ b/src/Support/Cache/FileStore.php @@ -0,0 +1,77 @@ +getPath($name); + + $this->makeDir(dirname($path)); + + return file_put_contents($path, serialize($data)); + } + + /** + * Retrieve data from cache. + * + * @param string $name + * @return mixed|null + */ + public function get($name) + { + if (file_exists($this->getPath($name))) { + return unserialize(file_get_contents($this->getPath($name))); + } + + return null; + } + + /** + * Remove the cache directory. + * + * @return void + */ + public function flush() + { + $path = $this->getPath(''); + + if (file_exists($path)) { + collect(array_diff(scandir($path), ['..', '.']))->each(function ($file) { + unlink($this->getPath($file)); + }); + } + } + + /** + * Get path name of item. + * + * @param string $name + * @return string + */ + protected function getPath($name) + { + return storage_path('graphql/cache/'.strtolower($name)); + } + + /** + * Make a directory tree recursively. + * + * @param string $dir + * @return void + */ + public function makeDir($dir) + { + if (! is_dir($dir)) { + mkdir($dir, 0777, true); + } + } +} diff --git a/src/Support/ConnectionResolver.php b/src/Support/ConnectionResolver.php new file mode 100644 index 0000000..14df096 --- /dev/null +++ b/src/Support/ConnectionResolver.php @@ -0,0 +1,120 @@ +getItems($root, $info, $name); + + if (isset($args['first'])) { + $total = $items->count(); + $first = $args['first']; + $after = $this->decodeCursor($args); + $currentPage = $first && $after ? floor(($first + $after) / $first) : 1; + + return new Paginator( + $items->slice($after)->take($first), + $total, + $first, + $currentPage + ); + } + + return new Paginator( + $items, + count($items), + count($items) + ); + } + + /** + * @param $collection + * @param ResolveInfo $info + * @param $name + * @return mixed|Collection + */ + protected function getItems($collection, ResolveInfo $info, $name) + { + $items = []; + + if ($collection instanceof Model) { + if (in_array($name, array_keys($collection->getRelations()))) { + return $collection->{$name}; + } + + $items = method_exists($collection, $name) + ? $collection->{$name}()->get() //->select(...$this->getSelectFields($info))->get() + : $collection->getAttribute($name); + return $items; + } elseif (is_object($collection) && method_exists($collection, 'get')) { + $items = $collection->get($name); + return $items; + } elseif (is_array($collection) && isset($collection[$name])) { + return collect($collection[$name]); + } + + return $items; + } + + /** + * Select only certain fields on queries instead of all fields. + * + * @param ResolveInfo $info + * @return array + */ + protected function getSelectFields(ResolveInfo $info) + { + $camel = config('relay.eloquent.camel_case'); + + return collect($info->getFieldSelection(4)['edges']['node']) + ->reject(function ($value) { + is_array($value); + })->keys()->transform(function ($value) use ($camel) { + if ($camel) { + return snake_case($value); + } + + return $value; + })->toArray(); + } + + /** + * Decode cursor from query arguments. + * + * @param array $args + * @return integer + */ + public function decodeCursor(array $args) + { + return isset($args['after']) ? $this->getCursorId($args['after']) : 0; + } + + /** + * Get id from encoded cursor. + * + * @param string $cursor + * @return integer + */ + protected function getCursorId($cursor) + { + return (int)$this->decodeRelayId($cursor); + } +} diff --git a/src/Support/Definition/EdgeType.php b/src/Support/Definition/EdgeType.php new file mode 100644 index 0000000..bdeb994 --- /dev/null +++ b/src/Support/Definition/EdgeType.php @@ -0,0 +1,113 @@ +name = $name; + $this->type = $type; + } + + /** + * Fields that exist on every connection. + * + * @return array + */ + public function fields() + { + return [ + 'node' => [ + 'type' => function () { + if (is_object($this->type)) { + return $this->type; + } + + return $this->getNodeType($this->type); + }, + 'description' => 'The item at the end of the edge.', + 'resolve' => function ($edge) { + return $edge; + }, + ], + 'cursor' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => 'A cursor for use in pagination.', + 'resolve' => function ($edge) { + if (is_array($edge) && isset($edge['relayCursor'])) { + return $edge['relayCursor']; + } elseif (is_array($edge->attributes)) { + return $edge->attributes['relayCursor']; + } + + return $edge->relayCursor; + }, + ] + ]; + } + + /** + * Convert the Fluent instance to an array. + * + * @return array + */ + public function toArray() + { + return [ + 'name' => ucfirst($this->name).'Edge', + 'description' => 'An edge in a connection.', + 'fields' => $this->fields(), + ]; + } + + /** + * Create the instance of the connection type. + * + * @return ObjectType + */ + public function toType() + { + return new ObjectType($this->toArray()); + } + + /** + * Get node at the end of the edge. + * + * @param string $name + * @return \GraphQL\Type\Definition\OutputType + */ + protected function getNodeType($name) + { + $graphql = app('graphql'); + + return $graphql->hasType($this->type) ? $graphql->getType($this->type) : $graphql->type($this->type); + } +} diff --git a/src/Support/Definition/EloquentType.php b/src/Support/Definition/EloquentType.php new file mode 100644 index 0000000..e41d3e5 --- /dev/null +++ b/src/Support/Definition/EloquentType.php @@ -0,0 +1,327 @@ +name = $name; + $this->fields = collect(); + $this->hiddenFields = collect($model->getHidden())->flip(); + $this->model = $model; + $this->camelCase = config('relay.eloquent.camel_case', false); + } + + /** + * Transform eloquent model to graphql type. + * + * @return \GraphQL\Type\Definition\ObjectType + */ + public function toType() + { + $graphql = app('graphql'); + $name = $this->getName(); + + if ($fields = $graphql->cache()->get($name)) { + $this->fields = $fields; + } else { + $this->schemaFields(); + $graphql->cache()->store($name, $this->fields); + } + + if (method_exists($this->model, 'graphqlFields')) { + $this->eloquentFields(collect($this->model->graphqlFields())); + } + + if (method_exists($this->model, $this->getTypeMethod())) { + $method = $this->getTypeMethod(); + $this->eloquentFields(collect($this->model->{$method}())); + } + + return new ObjectType([ + 'name' => $name, + 'description' => $this->getDescription(), + 'fields' => $this->fields->toArray() + ]); + } + + /** + * Get fields for model. + * + * @return \Illuminate\Support\DefinitionsCollection + */ + public function rawFields() + { + $this->schemaFields(); + + if (method_exists($this->model, 'graphqlFields')) { + $this->eloquentFields(collect($this->model->graphqlFields())); + } + + if (method_exists($this->model, $this->getTypeMethod())) { + $method = $this->getTypeMethod(); + $this->eloquentFields(collect($this->model->{$method}())); + } + + return $this->fields->transform(function ($field, $key) { + $field['type'] = $this->getRawType($field['type']); + + return $field; + }); + } + + /** + * Convert eloquent defined fields. + * + * @param \Illuminate\Support\Collection + * @return array + */ + public function eloquentFields(Collection $fields) + { + $fields->each(function ($field, $key) { + if (!$this->skipField($key)) { + $data = []; + $data['type'] = $field['type']; + $data['description'] = isset($field['description']) ? $field['description'] : null; + + if (isset($field['resolve'])) { + $data['resolve'] = $field['resolve']; + } elseif ($method = $this->getModelResolve($key)) { + $data['resolve'] = $method; + } + + $this->addField($key, $field); + } + }); + } + + /** + * Create fields for type. + * + * @return void + */ + protected function schemaFields() + { + $table = $this->model->getTable(); + $schema = $this->model->getConnection()->getSchemaBuilder(); + $columns = collect($schema->getColumnListing($table)); + + $columns->each(function ($column) use ($table, $schema) { + if (!$this->skipField($column)) { + $this->generateField( + $column, + $schema->getColumnType($table, $column) + ); + } + }); + } + + /** + * Generate type field from schema. + * + * @param string $name + * @param string $colType + * @return void + */ + protected function generateField($name, $colType) + { + $field = []; + $field['type'] = $this->resolveTypeByColumn($name, $colType); + $field['description'] = isset($this->descriptions['name']) ? $this->descriptions[$name] : null; + + if ($name === $this->model->getKeyName()) { + $field['description'] = $field['description'] ?: 'Primary id of type.'; + } + + if ($method = $this->getModelResolve($name)) { + $field['resolve'] = $method; + } + + $fieldName = $this->camelCase ? camel_case($name) : $name; + + $this->addField($fieldName, $field); + } + + /** + * Resolve field type by column info. + * + * @param string $name + * @param string $colType + * @return \GraphQL\Type\Definition\Type + */ + protected function resolveTypeByColumn($name, $colType) + { + $type = Type::string(); + $type->name = $this->getName().'_String'; + + if ($name === $this->model->getKeyName()) { + $type = Type::id(); + $type->name = $this->getName().'_ID'; + } elseif ($colType === 'integer') { + $type = Type::int(); + $type->name = $this->getName().'_Int'; + } elseif ($colType === 'float' || $colType === 'decimal') { + $type = Type::float(); + $type->name = $this->getName().'_Float'; + } elseif ($colType === 'boolean') { + $type = Type::boolean(); + $type->name = $this->getName().'_Boolean'; + } + + return $type; + } + + /** + * Get raw name for type. + * + * @param Type $type + * @return string + */ + protected function getRawType(Type $type) + { + $class = get_class($type); + $namespace = 'GraphQL\\Type\\Definition\\'; + + if ($class == $namespace . 'NonNull') { + return 'Type::nonNull('. $this->getRawType($type->getWrappedType()) .')'; + } elseif ($class == $namespace . 'IDType') { + return 'Type::id()'; + } elseif ($class == $namespace . 'IntType') { + return 'Type::int()'; + } elseif ($class == $namespace . 'BooleanType') { + return 'Type::bool()'; + } elseif ($class == $namespace . 'FloatType') { + return 'Type::float()'; + } + + return 'Type::string()'; + } + + /** + * Add field to collection. + * + * @param string $name + * @param array $field + */ + protected function addField($name, $field) + { + $name = $this->camelCase ? camel_case($name) : $name; + + $this->fields->put($name, $field); + } + + /** + * Check if field should be skipped. + * + * @param string $field + * @return boolean + */ + protected function skipField($name = '') + { + if ($this->hiddenFields->has($name) || $this->fields->has($name)) { + return true; + } + + return false; + } + + /** + * Check if model has resolve function. + * + * @param string $key + * @return string|null + */ + protected function getModelResolve($key) + { + $method = 'resolve' . studly_case($key) . 'Field'; + + if (method_exists($this->model, $method)) { + return array($this->model, $method); + } + + return null; + } + + /** + * Get name for type. + * + * @return string + */ + protected function getName() + { + if ($this->name) { + return studly_case($this->name); + } + + return $this->model->name ?: ucfirst((new ReflectionClass($this->model))->getShortName()); + } + + /** + * Get description of type. + * + * @return string + */ + protected function getDescription() + { + return $this->model->description ?: null; + } + + /** + * Get method name for type. + * + * @return string + */ + protected function getTypeMethod() + { + return 'graphql'.$this->getName().'Fields'; + } +} diff --git a/src/Support/Definition/GraphQLField.php b/src/Support/Definition/GraphQLField.php new file mode 100644 index 0000000..7e69bd8 --- /dev/null +++ b/src/Support/Definition/GraphQLField.php @@ -0,0 +1,178 @@ +attributes, [ + 'args' => $this->args() + ], $this->attributes()); + + $attributes['type'] = $this->type(); + + $attributes['resolve'] = $this->getResolver(); + + return $attributes; + } + + /** + * Get rules for field. + * + * @return array + */ + public function getRules() + { + $arguments = func_get_args(); + $args = $this->args(); + + if ($this instanceof RelayMutation) { + $args = $this->inputFields(); + } + + return collect($args) + ->transform(function ($arg, $name) use ($arguments) { + if (isset($arg['rules'])) { + if (is_callable($arg['rules'])) { + return call_user_func_array($arg['rules'], $arguments); + } + return $arg['rules']; + } + return null; + }) + ->merge(call_user_func_array([$this, 'rules'], $arguments)) + ->reject(function ($arg) { + return is_null($arg); + }) + ->toArray(); + } + + /** + * Get the field resolver. + * + * @return \Closure|null + */ + protected function getResolver() + { + if (!method_exists($this, 'resolve')) { + return null; + } + + $resolver = array($this, 'resolve'); + + return function () use ($resolver) { + $arguments = func_get_args(); + $rules = call_user_func_array([$this, 'getRules'], $arguments); + + if (sizeof($rules)) { + $input = $this->getInput($arguments); + $validator = app('validator')->make($input, $rules); + + if ($validator->fails()) { + throw with(new ValidationError('validation'))->setValidator($validator); + } + } + + return call_user_func_array($resolver, $arguments); + }; + } + + /** + * Get input for validation. + * + * @param array $arguments + * @return array + */ + protected function getInput(array $arguments) + { + return array_get($arguments, 1, []); + } + + /** + * Convert the Fluent instance to an array. + * + * @return array + */ + public function toArray() + { + return $this->getAttributes(); + } + + /** + * 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) + { + return isset($this->getAttributes()[$key]); + } +} diff --git a/src/Support/Definition/GraphQLInterface.php b/src/Support/Definition/GraphQLInterface.php new file mode 100644 index 0000000..db79794 --- /dev/null +++ b/src/Support/Definition/GraphQLInterface.php @@ -0,0 +1,48 @@ +getTypeResolver(); + if(isset($resolver)) + { + $attributes['resolveType'] = $resolver; + } + + return $attributes; + } + + public function toType() + { + return new InterfaceType($this->toArray()); + } + +} diff --git a/src/Support/Definition/GraphQLMutation.php b/src/Support/Definition/GraphQLMutation.php new file mode 100644 index 0000000..7c9e138 --- /dev/null +++ b/src/Support/Definition/GraphQLMutation.php @@ -0,0 +1,8 @@ +attributes, [ + 'fields' => $this->getFields(), + ]); + + if(sizeof($this->interfaces())) { + $attributes['interfaces'] = $this->interfaces(); + } + + return $attributes; + } + + /** + * The resolver for a specific field. + * + * @param $name + * @param $field + * @return \Closure|null + */ + protected function getFieldResolver($name, $field) + { + if(isset($field['resolve'])) { + return $field['resolve']; + } else if(method_exists($this, 'resolve'.studly_case($name).'Field')) { + $resolver = array($this, 'resolve'.studly_case($name).'Field'); + + return function() use ($resolver) { + return call_user_func_array($resolver, func_get_args()); + }; + } + + return null; + } + + /** + * Get the fields of the type. + * + * @return array + */ + public function getFields() + { + $collection = new Collection($this->fields()); + + return $collection->transform(function ($field, $name) { + if(is_string($field)) { + $field = app($field); + + $field->name = $name; + + return $field->toArray(); + } else { + $resolver = $this->getFieldResolver($name, $field); + + if ($resolver) { + $field['resolve'] = $resolver; + } + + return $field; + } + })->toArray(); + } + + /** + * Type interfaces. + * + * @return array + */ + public function interfaces() + { + return []; + } + + /** + * Convert the object to an array. + * + * @return array + */ + public function toArray() + { + return $this->getAttributes(); + } + + /** + * Convert this class to its ObjectType. + * + * @return ObjectType + */ + public function toType() + { + return new ObjectType($this->toArray()); + } + + /** + * 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) + { + return isset($this->getAttributes()[$key]); + } +} diff --git a/src/Support/Definition/PageInfoType.php b/src/Support/Definition/PageInfoType.php new file mode 100644 index 0000000..1647512 --- /dev/null +++ b/src/Support/Definition/PageInfoType.php @@ -0,0 +1,85 @@ + 'pageInfo', + 'description' => 'Information to aid in pagination.' + ]; + + /** + * Fields available on PageInfo. + * + * @return array + */ + public function fields() + { + return [ + 'hasNextPage' => [ + 'type' => Type::nonNull(Type::boolean()), + 'description' => 'When paginating forwards, are there more items?', + 'resolve' => function ($collection) { + if ($collection instanceof LengthAwarePaginator) { + return $collection->hasMorePages(); + } + + return false; + } + ], + 'hasPreviousPage' => [ + 'type' => Type::nonNull(Type::boolean()), + 'description' => 'When paginating backwards, are there more items?', + 'resolve' => function ($collection) { + if ($collection instanceof LengthAwarePaginator) { + return $collection->currentPage() > 1; + } + + return false; + } + ], + 'startCursor' => [ + 'type' => Type::string(), + 'description' => 'When paginating backwards, the cursor to continue.', + 'resolve' => function ($collection) { + if ($collection instanceof LengthAwarePaginator) { + return $this->encodeGlobalId( + 'arrayconnection', + $collection->firstItem() * $collection->currentPage() + ); + } + + return null; + } + ], + 'endCursor' => [ + 'type' => Type::string(), + 'description' => 'When paginating forwards, the cursor to continue.', + 'resolve' => function ($collection) { + if ($collection instanceof LengthAwarePaginator) { + return $this->encodeGlobalId( + 'arrayconnection', + $collection->lastItem() * $collection->currentPage() + ); + } + + return null; + } + ] + ]; + } +} diff --git a/src/Support/Definition/RelayConnectionType.php b/src/Support/Definition/RelayConnectionType.php new file mode 100644 index 0000000..c599dc6 --- /dev/null +++ b/src/Support/Definition/RelayConnectionType.php @@ -0,0 +1,253 @@ +type ?: $this->type(); + + return [ + 'pageInfo' => [ + 'type' => Type::nonNull(GraphQL::type('pageInfo')), + 'description' => 'Information to aid in pagination.', + 'resolve' => function ($collection) { + return $collection; + }, + ], + 'edges' => [ + 'type' => Type::listOf($this->buildEdgeType($this->name, $type)), + 'description' => 'Information to aid in pagination.', + 'resolve' => function ($collection) { + return $this->injectCursor($collection); + }, + ] + ]; + } + + /** + * Get the default arguments for a connection. + * + * @return array + */ + public static function connectionArgs() + { + return [ + 'after' => [ + 'type' => Type::string() + ], + 'first' => [ + 'type' => Type::int() + ], + 'before' => [ + 'type' => Type::string() + ], + 'last' => [ + 'type' => Type::int() + ] + ]; + } + + /** + * Build the edge type for this connection. + * + * @param $name + * @param $type + * @return ObjectType + */ + protected function buildEdgeType($name, $type) + { + if (preg_match('/Connection$/', $name)) { + $name = substr($name, 0, strlen($name) - 10); + } + + $edge = new EdgeType($name, $type); + + return $edge->toType(); + } + + /** + * Inject encoded cursor into collection items. + * + * @param mixed $collection + * @return mixed + */ + protected function injectCursor($collection) + { + if ($collection instanceof LengthAwarePaginator) { + $page = $collection->currentPage(); + + $collection->each(function ($item, $x) use ($page) { + $cursor = ($x + 1) * $page; + $encodedCursor = $this->encodeGlobalId('arrayconnection', $cursor); + if (is_array($item)) { + $item['relayCursor'] = $encodedCursor; + } else { + if (is_object($item) && is_array($item->attributes)) { + $item->attributes['relayCursor'] = $encodedCursor; + } else { + $item->relayCursor = $encodedCursor; + } + } + }); + } + + return $collection; + } + + /** + * Get id from encoded cursor. + * + * @param string $cursor + * @return integer + */ + protected function getCursorId($cursor) + { + return (int)$this->decodeRelayId($cursor); + } + + /** + * Convert the Fluent instance to an array. + * + * @return array + */ + public function toArray() + { + $fields = array_merge($this->baseFields(), $this->fields()); + + return [ + 'name' => ucfirst($this->name), + 'description' => 'A connection to a list of items.', + 'fields' => $fields, + 'resolve' => function ($root, $args, ResolveInfo $info) { + return $this->resolve($root, $args, $info, $this->name); + } + ]; + } + + /** + * Create the instance of the connection type. + * + * @param Closure $pageInfoResolver + * @param Closure $edgeResolver + * @return ObjectType + */ + public function toType(Closure $pageInfoResolver = null, Closure $edgeResolver = null) + { + $this->pageInfoResolver = $pageInfoResolver; + + $this->edgeResolver = $edgeResolver; + + return new ObjectType($this->toArray()); + } + + /** + * Set the type at the end of the connection. + * + * @param Type $type + */ + public function setEdgeType($type) + { + $this->type = $type; + } + + /** + * Set name of connection. + * + * @param string $name + */ + public function setName($name) + { + $this->name = $name; + } + + /** + * 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 boolean + */ + public function __isset($key) + { + return isset($this->getAttributes()[$key]); + } + + /** + * Get the type of nodes at the end of this connection. + * + * @return mixed + */ + public function type() + { + return null; + } +} diff --git a/src/Mutations/MutationWithClientId.php b/src/Support/Definition/RelayMutation.php similarity index 53% rename from src/Mutations/MutationWithClientId.php rename to src/Support/Definition/RelayMutation.php index c22ea7e..7a8bdb8 100644 --- a/src/Mutations/MutationWithClientId.php +++ b/src/Support/Definition/RelayMutation.php @@ -1,17 +1,16 @@ ucfirst($this->name()) . 'Payload', 'fields' => array_merge($this->outputFields(), [ 'clientMutationId' => [ - 'type' => Type::nonNull(Type::string()) + 'type' => Type::nonNull(Type::string()), + 'resolve' => function () { + return $this->clientMutationId; + } ] ]) ]); @@ -73,79 +82,24 @@ public function args() public function resolve($_, $args, ResolveInfo $info) { if ($this->mutatesRelayType && isset($args['input']['id'])) { + $args['input']['relay_id'] = $args['input']['id']; $args['input']['id'] = $this->decodeRelayId($args['input']['id']); } - $this->validateMutation($args); - $payload = $this->mutateAndGetPayload($args['input'], $info); + $this->clientMutationId = $args['input']['clientMutationId']; - return array_merge($payload, [ - 'clientMutationId' => $args['input']['clientMutationId'] - ]); + return $this->mutateAndGetPayload($args['input'], $info); } /** - * Get rules for relay mutation. + * Get input for validation. * + * @param array $arguments * @return array */ - public function getRules() - { - $arguments = func_get_args(); - - $rules = call_user_func_array([$this, 'rules'], $arguments); - $argsRules = []; - foreach ($this->inputFields() as $name => $arg) { - if (isset($arg['rules'])) { - if (is_callable($arg['rules'])) { - $argsRules[$name] = call_user_func_array($arg['rules'], $arguments); - } else { - $argsRules[$name] = $arg['rules']; - } - } - } - - return array_merge($argsRules, $rules); - } - - /** - * Get resolver for relay mutation. - * - * @return mixed - */ - protected function getResolver() + protected function getInput(array $arguments) { - if (!method_exists($this, 'resolve')) { - return null; - } - - $resolver = array($this, 'resolve'); - - return function () use ($resolver) { - $arguments = func_get_args(); - - return call_user_func_array($resolver, $arguments); - }; - } - - /** - * Validate relay mutation. - * - * @param array $args - * @throws ValidationError - * @return void - */ - protected function validateMutation(array $args) - { - $rules = call_user_func_array([$this, 'getRules'], $args); - - if (sizeof($rules)) { - $validator = Validator::make($args['input'], $rules); - - if ($validator->fails()) { - throw with(new ValidationError('validation'))->setValidator($validator); - } - } + return array_get($arguments, '1.input', []); } /** diff --git a/src/Support/Definition/RelayType.php b/src/Support/Definition/RelayType.php new file mode 100644 index 0000000..e51fc9e --- /dev/null +++ b/src/Support/Definition/RelayType.php @@ -0,0 +1,104 @@ +relayFields(), $this->getConnections(), [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => 'ID of type.', + 'resolve' => function ($obj) { + return $this->encodeGlobalId(get_called_class(), $this->getIdentifier($obj)); + }, + ], + ]); + } + + /** + * Available connections for type. + * + * @return array + */ + protected function connections() + { + return []; + } + + /** + * Generate Relay compliant edges. + * + * @return array + */ + public function getConnections() + { + return collect($this->connections())->transform(function ($edge, $name) { + if (!isset($edge['resolve'])) { + $edge['resolve'] = function ($root, array $args, ResolveInfo $info) use ($name) { + return GraphQL::resolveConnection($root, $args, $info, $name); + }; + } + + $edge['args'] = RelayConnectionType::connectionArgs(); + + return $edge; + + })->toArray(); + } + + /** + * Get the identifier of the type. + * + * @param mixed $obj + * @return mixed + */ + public function getIdentifier($obj) + { + return $obj->id; + } + + /** + * List of available interfaces. + * + * @return array + */ + public function interfaces() + { + return [GraphQL::type('node')]; + } + + /** + * Get list of available fields for type. + * + * @return array + */ + abstract protected function relayFields(); + + /** + * Fetch type data by id. + * + * @param string $id + * + * @return mixed + */ + abstract public function resolveById($id); +} diff --git a/src/SchemaGenerator.php b/src/Support/SchemaGenerator.php similarity index 82% rename from src/SchemaGenerator.php rename to src/Support/SchemaGenerator.php index dd22423..141232a 100644 --- a/src/SchemaGenerator.php +++ b/src/Support/SchemaGenerator.php @@ -1,6 +1,6 @@ put($path, $schema); } diff --git a/src/Support/ValidationError.php b/src/Support/ValidationError.php new file mode 100644 index 0000000..c7ada61 --- /dev/null +++ b/src/Support/ValidationError.php @@ -0,0 +1,37 @@ +validator = $validator; + + return $this; + } + + /** + * Get the messages from the validator. + * + * @return array + */ + public function getValidatorMessages() + { + return $this->validator ? $this->validator->messages() : []; + } +} diff --git a/src/GlobalIdTrait.php b/src/Traits/GlobalIdTrait.php similarity index 96% rename from src/GlobalIdTrait.php rename to src/Traits/GlobalIdTrait.php index 8f852b9..866be0f 100644 --- a/src/GlobalIdTrait.php +++ b/src/Traits/GlobalIdTrait.php @@ -1,6 +1,6 @@ type()->getFields() as $name => $field) { @@ -84,7 +84,7 @@ protected function availableOutputFields($mutationName) $objectType = $field->getType(); if ($objectType instanceof ObjectType) { - $fields = array_keys($objectType->getFields()); + $fields = $this->includeOutputFields($objectType); $outputFields[] = $name . '{'. implode(',', $fields) .'}'; } } @@ -92,4 +92,31 @@ protected function availableOutputFields($mutationName) return $outputFields; } + + /** + * Determine if output fields should be included. + * + * @param mixed $objectType + * @return boolean + */ + protected function includeOutputFields(ObjectType $objectType) + { + $fields = []; + + foreach ($objectType->getFields() as $name => $field) { + $type = $field->getType(); + + if ($type instanceof ObjectType) { + $config = $type->config; + + if (isset($config['name']) && preg_match('/Connection$/', $config['name'])) { + continue; + } + } + + $fields[] = $name; + } + + return $fields; + } } diff --git a/src/Traits/RelayMiddleware.php b/src/Traits/RelayMiddleware.php index c911a22..17d9edc 100644 --- a/src/Traits/RelayMiddleware.php +++ b/src/Traits/RelayMiddleware.php @@ -9,44 +9,36 @@ trait RelayMiddleware { /** - * Middleware to be attached to GraphQL query. + * Genarate middleware and connections from query. * - * @var array + * @param Request $request + * @return array */ - protected $relayMiddleware = []; + public function setupQuery(Request $request) + { + $relay = app('relay'); + $relay->setupRequest($request->get('query')); + + foreach ($relay->middleware() as $middleware) { + $this->middleware($middleware); + } + } /** - * Genarate middleware to be run on query. + * Process GraphQL query. * * @param Request $request - * @return array + * @return Response */ - public function queryMiddleware(Request $request) + public function graphqlQuery(Request $request) { - $relay = app('relay'); - $source = new Source($request->get('query', 'GraphQL request')); - $ast = Parser::parse($source); - - if (isset($ast->definitions[0])) { - $d = $ast->definitions[0]; - $operation = $d->operation ?: 'query'; - $selectionSet = $d->selectionSet->selections; - - foreach ($selectionSet as $selection) { - if (is_object($selection) && $selection instanceof \GraphQL\Language\AST\Field) { - try { - $schema = $relay->find($selection->name->value, $operation); + $query = $request->get('query'); + $params = $request->get('variables'); - if (isset($schema['middleware']) && !empty($schema['middleware'])) { - $this->relayMiddleware = array_merge($this->relayMiddleware, $schema['middleware']); - } - } catch (\Exception $e) { - continue; - } - } - } + if (is_string($params)) { + $params = json_decode($params, true); } - return array_unique($this->relayMiddleware); + return app('graphql')->query($query, $params); } } diff --git a/src/RelayModelTrait.php b/src/Traits/RelayModelTrait.php similarity index 73% rename from src/RelayModelTrait.php rename to src/Traits/RelayModelTrait.php index 995f24f..605054b 100644 --- a/src/RelayModelTrait.php +++ b/src/Traits/RelayModelTrait.php @@ -1,6 +1,6 @@ attributes[$this->getKeyName()]; } diff --git a/src/Types/PageInfoType.php b/src/Types/PageInfoType.php deleted file mode 100644 index f0d7abe..0000000 --- a/src/Types/PageInfoType.php +++ /dev/null @@ -1,54 +0,0 @@ - 'PageInfo', - 'description' => 'Information about pagination in a connection.' - ]; - - /** - * Fields available on PageInfo. - * - * @return array - */ - public function fields() - { - return [ - 'hasNextPage' => [ - 'type' => Type::nonNull(Type::boolean()), - 'description' => 'When paginating forwards, are there more items?', - 'resolve' => function ($collection, $test) { - if ($collection instanceof LengthAwarePaginator) { - return $collection->hasMorePages(); - } - - return false; - } - ], - 'hasPreviousPage' => [ - 'type' => Type::nonNull(Type::boolean()), - 'description' => 'When paginating backwards, are there more items?', - 'resolve' => function ($collection) { - if ($collection instanceof LengthAwarePaginator) { - return $collection->currentPage() > 1; - } - - return false; - } - ] - ]; - } -} diff --git a/src/Types/RelayType.php b/src/Types/RelayType.php deleted file mode 100644 index 5623fe4..0000000 --- a/src/Types/RelayType.php +++ /dev/null @@ -1,303 +0,0 @@ -relayFields(), $this->getConnections(), [ - 'id' => [ - 'type' => Type::nonNull(Type::id()), - 'description' => 'ID of type.', - 'resolve' => function ($obj) { - return $this->encodeGlobalId(get_called_class(), $this->getIdentifier($obj)); - }, - ], - ]); - } - - /** - * Get the identifier of the type. - * - * @param mixed $obj - * @return mixed - */ - public function getIdentifier($obj) - { - return $obj->id; - } - - /** - * List of available interfaces. - * - * @return array - */ - public function interfaces() - { - return [GraphQL::type('node')]; - } - - /** - * Generate Relay compliant edges. - * - * @return array - */ - public function getConnections() - { - $edges = []; - - foreach ($this->connections() as $name => $edge) { - $injectCursor = isset($edge['injectCursor']) ? $edge['injectCursor'] : null; - $resolveCursor = isset($edge['resolveCursor']) ? $edge['resolveCursor'] : null; - - $edgeType = $this->edgeType($name, $edge['type'], $resolveCursor); - $connectionType = $this->connectionType($name, Type::listOf($edgeType), $injectCursor); - - $edges[$name] = [ - 'type' => $connectionType, - 'description' => 'A connection to a list of items.', - 'args' => [ - 'first' => [ - 'name' => 'first', - 'type' => Type::int() - ], - 'after' => [ - 'name' => 'after', - 'type' => Type::string() - ] - ], - 'resolve' => isset($edge['resolve']) ? $edge['resolve'] : function ($collection, array $args, ResolveInfo $info) use ($name) { - $items = []; - - if ($collection instanceof Model) { - $items = $collection->getAttribute($name); - } else if (is_object($collection) && method_exists($collection, 'get')) { - $items = $collection->get($name); - } else if (is_array($collection) && isset($collection[$name])) { - $items = new Collection($collection[$name]); - } - - if (isset($args['first'])) { - $total = $items->count(); - $first = $args['first']; - $after = $this->decodeCursor($args); - $currentPage = $first && $after ? floor(($first + $after) / $first) : 1; - - return new Paginator( - $items->slice($after)->take($first), - $total, - $first, - $currentPage - ); - } - - return new Paginator( - $items, - count($items), - count($items) - ); - } - ]; - } - - return $edges; - } - - /** - * Generate PageInfo object type. - * - * @return ObjectType - */ - protected function pageInfoType() - { - return GraphQL::type('pageInfo'); - } - - /** - * Generate EdgeType. - * - * @param string $name - * @param mixed $type - * @return ObjectType - */ - protected function edgeType($name, $type, Closure $resolveCursor = null) - { - if ($type instanceof ListOfType) { - $type = $type->getWrappedType(); - } - - return new ObjectType([ - 'name' => ucfirst($name) . 'Edge', - 'fields' => [ - 'node' => [ - 'type' => $type, - 'description' => 'The item at the end of the edge.', - 'resolve' => function ($edge, array $args, ResolveInfo $info) { - return $edge; - } - ], - 'cursor' => [ - 'type' => Type::nonNull(Type::string()), - 'description' => 'A cursor for use in pagination.', - 'resolve' => function ($edge, array $args, ResolveInfo $info) use ($resolveCursor) { - if ($resolveCursor) { - return $resolveCursor($edge, $args, $info); - } - - return $this->resolveCursor($edge); - } - ] - ] - ]); - } - - /** - * Create ConnectionType. - * - * @param string $name - * @param mixed $type - * @return ObjectType - */ - protected function connectionType($name, $type, Closure $injectCursor = null) - { - if (!$type instanceof ListOfType) { - $type = Type::listOf($type); - } - - return new ObjectType([ - 'name' => ucfirst($name) . 'Connection', - 'fields' => [ - 'edges' => [ - 'type' => $type, - 'resolve' => function ($collection, array $args, ResolveInfo $info) use ($injectCursor) { - if ($injectCursor) { - return $injectCursor($collection, $args, $info); - } - - return $this->injectCursor($collection); - } - ], - 'pageInfo' => [ - 'type' => Type::nonNull($this->pageInfoType()), - 'description' => 'Information to aid in pagination.', - 'resolve' => function ($collection, array $args, ResolveInfo $info) { - return $collection; - } - ] - ] - ]); - } - - /** - * Inject encoded cursor into collection items. - * - * @param mixed $collection - * @return mixed - */ - protected function injectCursor($collection) - { - if ($collection instanceof LengthAwarePaginator) { - $page = $collection->currentPage(); - - foreach ($collection as $x => &$item) { - $cursor = ($x + 1) * $page; - $encodedCursor = $this->encodeGlobalId('arrayconnection', $cursor); - - if (is_array($item)) { - $item['relayCursor'] = $encodedCursor; - } else if (is_object($item) && is_array($item->attributes)) { - $item->attributes['relayCursor'] = $encodedCursor; - } else { - $item->relayCursor = $encodedCursor; - } - } - } - - return $collection; - } - - /** - * Resolve encoded relay cursor for item. - * - * @param mixed $edge - * @return string - */ - protected function resolveCursor($edge) - { - if (is_array($edge) && isset($edge['relayCursor'])) { - return $edge['relayCursor']; - } elseif (is_array($edge->attributes)) { - return $edge->attributes['relayCursor']; - } - - return $edge->relayCursor; - } - - /** - * Decode cursor from query arguments. - * - * @param array $args - * @return integer - */ - public function decodeCursor(array $args) - { - return isset($args['after']) ? $this->getCursorId($args['after']) : 0; - } - - /** - * Get id from encoded cursor. - * - * @param string $cursor - * @return integer - */ - protected function getCursorId($cursor) - { - return (int)$this->decodeRelayId($cursor); - } - - /** - * Available connections for type. - * - * @return array - */ - protected function connections() - { - return []; - } - - /** - * Get list of available fields for type. - * - * @return array - */ - abstract protected function relayFields(); - - /** - * Fetch type data by id. - * - * @param string $id - * @return mixed - */ - abstract public function resolveById($id); -} diff --git a/tests/BaseTest.php b/tests/BaseTest.php index 046672b..a539964 100644 --- a/tests/BaseTest.php +++ b/tests/BaseTest.php @@ -32,8 +32,7 @@ protected function graphqlResponse($query, $variables = [], $encode = false) protected function getPackageProviders($app) { return [ - \Folklore\GraphQL\GraphQLServiceProvider::class, - \Nuwave\Relay\ServiceProvider::class, + \Nuwave\Relay\LaravelServiceProvider::class, ]; } @@ -46,7 +45,8 @@ protected function getPackageProviders($app) protected function getPackageAliases($app) { return [ - 'GraphQL' => \Folklore\GraphQL\Support\Facades\GraphQL::class, + 'GraphQL' => \Nuwave\Relay\Facades\GraphQL::class, + 'Relay' => \Nuwave\Relay\Facades\Relay::class, ]; } @@ -58,24 +58,19 @@ protected function getPackageAliases($app) */ protected function getEnvironmentSetUp($app) { - $app['config']->set('graphql', [ - 'prefix' => 'graphql', - 'routes' => '/', - 'controllers' => '\Folklore\GraphQL\GraphQLController@query', - 'middleware' => [], + $app['config']->set('relay', [ 'schema' => [ - 'query' => [ + 'path' => 'schema/schema.php', + 'queries' => [ 'node' => \Nuwave\Relay\Node\NodeQuery::class, 'humanByName' => \Nuwave\Relay\Tests\Assets\Queries\HumanByName::class, ], - 'mutation' => [ - - ] - ], - 'types' => [ - 'node' => \Nuwave\Relay\Node\NodeType::class, - 'pageInfo' => \Nuwave\Relay\Types\PageInfoType::class, - 'human' => \Nuwave\Relay\Tests\Assets\Types\HumanType::class, + 'mutations' => [], + 'types' => [ + 'node' => \Nuwave\Relay\Node\NodeType::class, + 'pageInfo' => \Nuwave\Relay\Support\Definition\PageInfoType::class, + 'human' => \Nuwave\Relay\Tests\Assets\Types\HumanType::class, + ], ] ]); } diff --git a/tests/assets/Queries/UpdateHeroNameQuery.php b/tests/assets/Queries/UpdateHeroNameQuery.php index 984b387..e3cd3ee 100644 --- a/tests/assets/Queries/UpdateHeroNameQuery.php +++ b/tests/assets/Queries/UpdateHeroNameQuery.php @@ -3,9 +3,9 @@ namespace Nuwave\Relay\Tests\Assets; use GraphQL\Type\Definition\ResolveInfo; -use Nuwave\Relay\Mutations\MutationWithClientId; +use Nuwave\Relay\Support\Definition\RelayMutation; -class UpdateHeroNameQuery extends MutationWithClientId +class UpdateHeroNameQuery extends RelayMutation { /** * Name of Mutation. diff --git a/tests/assets/Types/HumanType.php b/tests/assets/Types/HumanType.php index 2790cb1..3c86781 100644 --- a/tests/assets/Types/HumanType.php +++ b/tests/assets/Types/HumanType.php @@ -2,7 +2,7 @@ namespace Nuwave\Relay\Tests\Assets\Types; -use Nuwave\Relay\Types\RelayType; +use Nuwave\Relay\Support\Definition\RelayType; use Nuwave\Relay\Tests\Assets\Data\StarWarsData; use GraphQL\Type\Definition\Type;