├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── composer.json ├── docs └── README-zh.md ├── phpunit.xml.dist ├── src ├── ErrorFormatter.php ├── GraphQL.php ├── GraphQLAction.php ├── GraphQLModuleTrait.php ├── TypeResolution.php ├── base │ ├── GraphQLField.php │ ├── GraphQLInterfaceType.php │ ├── GraphQLModel.php │ ├── GraphQLMutation.php │ ├── GraphQLQuery.php │ ├── GraphQLType.php │ └── GraphQLUnionType.php ├── exceptions │ ├── SchemaNotFound.php │ ├── TypeNotFound.php │ └── ValidatorException.php ├── filters │ └── auth │ │ └── CompositeAuth.php ├── traits │ ├── GlobalIdTrait.php │ └── ShouldValidate.php └── types │ ├── EmailType.php │ ├── PageInfoType.php │ ├── PaginationType.php │ ├── SimpleExpressionType.php │ └── UrlType.php └── tests ├── DefaultController.php ├── GraphQLActionTest.php ├── GraphQLTest.php ├── GraphqlMutationTest.php ├── GraphqlQueryTest.php ├── Module.php ├── TestCase.php ├── bootstrap.php ├── data ├── Comment.php ├── DataSource.php ├── Image.php ├── Story.php └── User.php ├── objects ├── mutation │ └── UpdateUserPwdMutation.php ├── queries.php ├── query │ ├── HelloQuery.php │ ├── LastStoryPostedQuery.php │ ├── NodeQuery.php │ ├── SearchQuery.php │ ├── StoryListQuery.php │ ├── UserQuery.php │ └── ViewerQuery.php └── types │ ├── CommentType.php │ ├── ContentFormatEnumType.php │ ├── ExampleType.php │ ├── HtmlField.php │ ├── ImageSizeEnumType.php │ ├── ImageType.php │ ├── NodeType.php │ ├── ResultItemConnectionType.php │ ├── ResultItemType.php │ ├── StoryType.php │ └── UserType.php └── types └── SimpleExpressionTypeTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DStore 2 | .idea 3 | vendor 4 | tests/runtime 5 | composer.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Travis Setup 3 | # 4 | 5 | # faster builds on new travis setup not using sudo 6 | sudo: false 7 | 8 | # build only on master branches 9 | # commented as this prevents people from running builds on their forks: 10 | # https://github.com/yiisoft/yii2/commit/bd87be990fa238c6d5e326d0a171f38d02dc253a 11 | #branches: 12 | # only: 13 | # - master 14 | # - 2.1 15 | 16 | 17 | # 18 | # Test Matrix 19 | # 20 | 21 | language: php 22 | 23 | env: 24 | global: 25 | - DEFAULT_COMPOSER_FLAGS="--prefer-dist --no-interaction --no-progress --optimize-autoloader" 26 | - TASK_TESTS_PHP=1 27 | - TASK_TESTS_JS=0 28 | - TASK_TESTS_COVERAGE=0 29 | 30 | # cache vendor dirs 31 | cache: 32 | directories: 33 | - vendor 34 | - $HOME/.composer/cache 35 | - $HOME/.npm 36 | 37 | matrix: 38 | fast_finish: true 39 | include: 40 | # run tests coverage on PHP 7.1 41 | - php: 7.1 42 | env: TASK_TESTS_COVERAGE=1 43 | 44 | - php: 7.0 45 | allow_failures: 46 | - php: 7.1 47 | 48 | install: 49 | - | 50 | if [[ $TASK_TESTS_COVERAGE != 1 && $TRAVIS_PHP_VERSION != hhv* ]]; then 51 | # disable xdebug for performance reasons when code coverage is not needed. note: xdebug on hhvm is disabled by default 52 | phpenv config-rm xdebug.ini || echo "xdebug is not installed" 53 | fi 54 | 55 | # install composer dependencies 56 | - travis_retry composer self-update 57 | - export PATH="$HOME/.composer/vendor/bin:$PATH" 58 | - travis_retry composer global require "fxp/composer-asset-plugin:^1.3.1" 59 | - travis_retry composer install $DEFAULT_COMPOSER_FLAGS 60 | 61 | before_script: 62 | # show some versions and env information 63 | - php --version 64 | - composer --version 65 | - | 66 | if [ $TASK_TESTS_JS == 1 ]; then 67 | node --version 68 | npm --version 69 | fi 70 | # enable code coverage 71 | - | 72 | if [ $TASK_TESTS_COVERAGE == 1 ]; then 73 | PHPUNIT_FLAGS="--coverage-clover=coverage.clover" 74 | fi 75 | 76 | 77 | script: 78 | # PHP tests 79 | - | 80 | if [ $TASK_TESTS_PHP == 1 ]; then 81 | vendor/bin/phpunit --verbose $PHPUNIT_FLAGS --configuration phpunit.xml.dist 82 | fi 83 | 84 | after_script: 85 | - | 86 | if [ $TASK_TESTS_COVERAGE == 1 ]; then 87 | travis_retry wget https://scrutinizer-ci.com/ocular.phar 88 | php ocular.phar code-coverage:upload --format=php-clover coverage.clover 89 | fi 90 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Yii2 Graphql Changelog 2 | ======================= 3 | # 0.13 4 | - update for graphql-php(v0.13) 5 | - add graphql-upload(v4.0.0) 6 | # 0.11 7 | - update for graphql-php(v0.11) 8 | - Enh: default ErrorFormatter log the error from graphql-php 9 | # 0.9.1 10 | - Enh: add the default errorFormat to format the model validator error,add "code" field to response errors segment. 11 | # 0.9 12 | - New support facebook graphql server -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | yii-graphql 2 | ========== 3 | Using Facebook [GraphQL](http://facebook.github.io/graphql/) PHP server implementation. Extends [graphql-php](https://github.com/webonyx/graphql-php) to apply to YII2. 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/tsingsun/yii2-graphql/v/stable.svg)](https://packagist.org/packages/tsingsun/yii2-graphql) 6 | [![Build Status](https://travis-ci.org/tsingsun/yii2-graphql.png?branch=master)](https://travis-ci.org/tsingsun/yii2-graphql) 7 | [![Total Downloads](https://poser.pugx.org/tsingsun/yii2-graphql/downloads.svg)](https://packagist.org/packages/tsingsun/yii2-graphql) 8 | 9 | -------- 10 | 11 | [Chinese document](/docs/README-zh.md) 12 | 13 | ------- 14 | 15 | Features 16 | 17 | * Configuration includes simplifying the definition of standard graphql protocols. 18 | * Based on the full name defined by the type, implementing on-demand loading and lazy loading, and no need to define all type definitions into the system at load. 19 | * Mutation input validation support. 20 | * Provide controller integration and authorization support. 21 | 22 | ### Install 23 | 24 | Using [composer](https://getcomposer.org/) 25 | ``` 26 | composer require tsingsun/yii2-graphql 27 | ``` 28 | 29 | ### Type 30 | The type system is the core of GraphQL, which is embodied in `GraphQLType`. By deconstructing the GraphQL protocol and using the [graph-php](https://github.com/webonyx/graphql-php) library to achieve fine-grained control of all elements, it is convenient to extend the class according to its own needs 31 | 32 | 33 | #### The main elements of `GraphQLType` 34 | 35 | The following elements can be declared in the `$attributes` property of the class, or as a method, unless stated otherwise. This also applies to all elements after this. 36 | 37 | Element | Type | Description 38 | ----- | ----- | ----- 39 | `name` | string | **Required** Each type needs to be named, with unique names preferred to resolve potential conflicts. The property needs to be defined in the `$attributes` property. 40 | `description` | string | A description of the type and its use. The property needs to be defined in the `$attributes` property. 41 | `fields` | array | **Required** The included field content is represented by the fields () method. 42 | `resolveField` | callback | **function($value, $args, $context, GraphQL\Type\Definition\ResolveInfo $info)** For the interpretation of a field. For example: the fields definition of the user property, the corresponding method is `resolveUserField()`, and `$value` is the passed type instance defined by `type`. 43 | 44 | ### Query 45 | 46 | `GraphQLQuery` and `GraphQLMutation` inherit `GraphQLField`. The element structure is consistent, and if you would like a reusable `Field`, you can inherit it. 47 | Each query of `Graphql` needs to correspond to a `GraphQLQuery` object 48 | 49 | #### The main elements of `GraphQLField` 50 | 51 | Element | Type | Description 52 | ----- | ----- | ----- 53 | `type` | ObjectType | For the corresponding query type. The single type is specified by `GraphQL::type`, and a list by `Type::listOf(GraphQL::type)`. 54 | `args` | array | The available query parameters, each of which is defined by `Field`. 55 | `resolve` | callback | **function($value, $args, $context, GraphQL\Type\Definition\ResolveInfo $info)** `$value` is the root data, `$args` is the query parameters, `$context` is the `yii\web\Application` object, and `$info` resolves the object for the query. The root object is handled in this method. 56 | 57 | ### Mutation 58 | 59 | Definition is similar to `GraphQLQuery`, please refer to the above. 60 | 61 | ### Simplified Field Definition 62 | 63 | Simplifies the declarations of `Field`, removing the need to defined as an array with the type key. 64 | 65 | #### Standard Definition 66 | 67 | ```php 68 | //... 69 | 'id' => [ 70 | 'type' => Type::id(), 71 | ], 72 | //... 73 | ``` 74 | 75 | #### Simplified Definition 76 | 77 | ```php 78 | //... 79 | 'id' => Type::id(), 80 | //... 81 | ``` 82 | 83 | ### Yii Implementation 84 | 85 | ### General configuration 86 | 87 | JsonParser configuration required 88 | 89 | ```php 90 | 'components' => [ 91 | 'request' => [ 92 | 'parsers' => [ 93 | 'application/json' => 'yii\web\JsonParser', 94 | ], 95 | ], 96 | ]; 97 | ``` 98 | 99 | #### Module support 100 | 101 | Can easily be implemented with `yii\graphql\GraphQLModuleTrait`. The trait is responsible for initialization. 102 | 103 | ```php 104 | class MyModule extends \yii\base\Module 105 | { 106 | use \yii\graphql\GraphQLModuleTrait; 107 | } 108 | ``` 109 | 110 | In your application configuration file: 111 | 112 | ```php 113 | 'modules'=>[ 114 | 'moduleName ' => [ 115 | 'class' => 'path\to\module' 116 | //graphql config 117 | 'schema' => [ 118 | 'query' => [ 119 | 'user' => 'app\graphql\query\UsersQuery' 120 | ], 121 | 'mutation' => [ 122 | 'login' 123 | ], 124 | // you do not need to set the types if your query contains interfaces or fragments 125 | // the key must same as your defined class 126 | 'types' => [ 127 | 'Story' => 'yiiunit\extensions\graphql\objects\types\StoryType' 128 | ], 129 | ], 130 | ], 131 | ]; 132 | ``` 133 | 134 | Use the controller to receive requests by using `yii\graphql\GraphQLAction` 135 | 136 | ```php 137 | class MyController extends Controller 138 | { 139 | function actions() { 140 | return [ 141 | 'index'=>[ 142 | 'class'=>'yii\graphql\GraphQLAction' 143 | ], 144 | ]; 145 | } 146 | } 147 | ``` 148 | 149 | #### Component Support 150 | also you can include the trait with your own components,then initialization yourself. 151 | ```php 152 | 'components'=>[ 153 | 'componentsName' => [ 154 | 'class' => 'path\to\components' 155 | //graphql config 156 | 'schema' => [ 157 | 'query' => [ 158 | 'user' => 'app\graphql\query\UsersQuery' 159 | ], 160 | 'mutation' => [ 161 | 'login' 162 | ], 163 | // you do not need to set the types if your query contains interfaces or fragments 164 | // the key must same as your defined class 165 | 'types'=>[ 166 | 'Story'=>'yiiunit\extensions\graphql\objects\types\StoryType' 167 | ], 168 | ], 169 | ], 170 | ]; 171 | ``` 172 | 173 | 174 | ### Input validation 175 | 176 | Validation rules are supported. 177 | In addition to graphql based validation, you can also use Yii Model validation, which is currently used for the validation of input parameters. The rules method is added directly to the mutation definition. 178 | 179 | ```php 180 | public function rules() { 181 | return [ 182 | ['password','boolean'] 183 | ]; 184 | } 185 | ``` 186 | 187 | ### Authorization verification 188 | 189 | Since graphql queries can be combined, such as when a query merges two query, and the two query have different authorization constraints, custom authentication is required. 190 | I refer to this query as "graphql actions"; when all graphql actions conditions are configured, it passes the authorization check. 191 | 192 | #### Authenticate 193 | 194 | In the behavior method of controller, the authorization method is set as follows 195 | 196 | ```php 197 | function behaviors() { 198 | return [ 199 | 'authenticator'=>[ 200 | 'class' => 'yii\graphql\filter\auth\CompositeAuth', 201 | 'authMethods' => [ 202 | \yii\filters\auth\QueryParamAuth::className(), 203 | ], 204 | 'except' => ['hello'] 205 | ], 206 | ]; 207 | } 208 | ``` 209 | If you want to support IntrospectionQuery authorization, the corresponding graphql action is `__schema` 210 | 211 | #### Authorization 212 | 213 | If the user has passed authentication, you may want to check the access for the resource. You can use `GraphqlAction`'s `checkAccess` method 214 | in the controller. It will check all graphql actions. 215 | 216 | ```php 217 | class GraphqlController extends Controller 218 | { 219 | public function actions() { 220 | return [ 221 | 'index' => [ 222 | 'class' => 'yii\graphql\GraphQLAction', 223 | 'checkAccess'=> [$this,'checkAccess'], 224 | ] 225 | ]; 226 | } 227 | 228 | /** 229 | * authorization 230 | * @param $actionName 231 | * @throws yii\web\ForbiddenHttpException 232 | */ 233 | public function checkAccess($actionName) { 234 | $permissionName = $this->module->id . '/' . $actionName; 235 | $pass = Yii::$app->getAuthManager()->checkAccess(Yii::$app->user->id,$permissionName); 236 | if (!$pass){ 237 | throw new yii\web\ForbiddenHttpException('Access Denied'); 238 | } 239 | } 240 | } 241 | ``` 242 | 243 | ### Demo 244 | 245 | #### Creating queries based on graphql protocols 246 | 247 | Each query corresponds to a GraphQLQuery file. 248 | 249 | ```php 250 | class UserQuery extends GraphQLQuery 251 | { 252 | public function type() { 253 | return GraphQL::type(UserType::class); 254 | } 255 | 256 | public function args() { 257 | return [ 258 | 'id'=>[ 259 | 'type' => Type::nonNull(Type::id()) 260 | ], 261 | ]; 262 | } 263 | 264 | public function resolve($value, $args, $context, ResolveInfo $info) { 265 | return DataSource::findUser($args['id']); 266 | } 267 | 268 | 269 | } 270 | ``` 271 | 272 | Define type files based on query protocols 273 | 274 | ```php 275 | 276 | class UserType extends GraphQLType 277 | { 278 | protected $attributes = [ 279 | 'name'=>'user', 280 | 'description'=>'user is user' 281 | ]; 282 | 283 | public function fields() 284 | { 285 | $result = [ 286 | 'id' => ['type'=>Type::id()], 287 | 'email' => Types::email(), 288 | 'email2' => Types::email(), 289 | 'photo' => [ 290 | 'type' => GraphQL::type(ImageType::class), 291 | 'description' => 'User photo URL', 292 | 'args' => [ 293 | 'size' => Type::nonNull(GraphQL::type(ImageSizeEnumType::class)), 294 | ] 295 | ], 296 | 'firstName' => [ 297 | 'type' => Type::string(), 298 | ], 299 | 'lastName' => [ 300 | 'type' => Type::string(), 301 | ], 302 | 'lastStoryPosted' => GraphQL::type(StoryType::class), 303 | 'fieldWithError' => [ 304 | 'type' => Type::string(), 305 | 'resolve' => function() { 306 | throw new \Exception("This is error field"); 307 | } 308 | ] 309 | ]; 310 | return $result; 311 | } 312 | 313 | public function resolvePhotoField(User $user,$args){ 314 | return DataSource::getUserPhoto($user->id, $args['size']); 315 | } 316 | 317 | public function resolveIdField(User $user, $args) 318 | { 319 | return $user->id.'test'; 320 | } 321 | 322 | public function resolveEmail2Field(User $user, $args) 323 | { 324 | return $user->email2.'test'; 325 | } 326 | 327 | 328 | } 329 | ``` 330 | 331 | #### Query instance 332 | 333 | ```php 334 | 'hello' => " 335 | query hello{hello} 336 | ", 337 | 338 | 'singleObject' => " 339 | query user { 340 | user(id:\"2\") { 341 | id 342 | email 343 | email2 344 | photo(size:ICON){ 345 | id 346 | url 347 | } 348 | firstName 349 | lastName 350 | 351 | } 352 | } 353 | ", 354 | 'multiObject' => " 355 | query multiObject { 356 | user(id: \"2\") { 357 | id 358 | email 359 | photo(size:ICON){ 360 | id 361 | url 362 | } 363 | } 364 | stories(after: \"1\") { 365 | id 366 | author{ 367 | id 368 | } 369 | body 370 | } 371 | } 372 | ", 373 | 'updateObject' => " 374 | mutation updateUserPwd{ 375 | updateUserPwd(id: \"1001\", password: \"123456\") { 376 | id, 377 | username 378 | } 379 | } 380 | " 381 | ``` 382 | 383 | ### Exception Handling 384 | 385 | You can config the error formater for graph. The default handle uses `yii\graphql\ErrorFormatter`, 386 | which optimizes the processing of Model validation results. 387 | 388 | ```php 389 | 'modules'=>[ 390 | 'moduleName' => [ 391 | 'class' => 'path\to\module' 392 | 'errorFormatter' => ['yii\graphql\ErrorFormatter', 'formatError'], 393 | ], 394 | ]; 395 | ``` 396 | 397 | ### Future 398 | 399 | * `ActiveRecord` tool for generating query and mutation class. 400 | * Some of the special syntax for graphql, such as `@Directives`, has not been tested 401 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"tsingsun/yii2-graphql", 3 | "description":"facebook graphql server side for yii2 php framework", 4 | "keyword":["yii2","graphql"], 5 | "type":"yii2-extension", 6 | "license": "BSD-3-Clause", 7 | "authors":[ 8 | { 9 | "name":"QingShan li", 10 | "email":"21997272@qq.com" 11 | } 12 | ], 13 | "minimum-stability":"dev", 14 | "require":{ 15 | "php": ">=5.6.0", 16 | "yiisoft/yii2":"~2.0.10", 17 | "webonyx/graphql-php": "^0.13.0", 18 | "ecodev/graphql-upload": "^4.0.0", 19 | "zendframework/zend-diactoros": "^2.1" 20 | }, 21 | "require-dev":{ 22 | "phpunit/phpunit": "4.8.*" 23 | }, 24 | "autoload":{ 25 | "psr-4":{ 26 | "yii\\graphql\\":"src" 27 | } 28 | }, 29 | "archive": { 30 | "exclude": [ 31 | "/tests" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/README-zh.md: -------------------------------------------------------------------------------- 1 | # yii-graphql # 2 | 3 | 使用 Facebook [GraphQL](http://facebook.github.io/graphql/) 的PHP服务端实现. 扩展 [graphql-php](https://github.com/webonyx/graphql-php) 以适用于 YII2. 4 | 5 | [![Latest Stable Version](https://poser.pugx.org/tsingsun/yii2-graphql/v/stable.svg)](https://packagist.org/packages/tsingsun/yii2-graphql) 6 | [![Build Status](https://travis-ci.org/tsingsun/yii2-graphql.png?branch=master)](https://travis-ci.org/tsingsun/yii2-graphql) 7 | [![Total Downloads](https://poser.pugx.org/tsingsun/yii2-graphql/downloads.svg)](https://packagist.org/packages/tsingsun/yii2-graphql) 8 | 9 | -------- 10 | 11 | yii-graphql特点 12 | 13 | * 配置简化,包括简化标准graphql协议的定义. 14 | * 按需要\懒加载,根据类型定义的全限定名,实现按需加载与懒,不需要在系统初始时将全部类型定义加载进入. 15 | * mutation输入验证支持 16 | * 提供控制器集成与授权支持 17 | 18 | ### 安装 ### 19 | 20 | 本库位于私有库中,需要在项目composer.json添加库地址 21 | ```php 22 | "require": { 23 | "tsingsun/yii2-graphql": "^0.9" 24 | } 25 | ``` 26 | 27 | ### Type ### 28 | 类型系统是GraphQL的核心,体现在GraphQLType中,通过解构graphql协议,并利用graph-php库达到细粒度的对所有元素的控制,方便根据自身需要进行类扩展. 29 | 30 | GraphQLType的主要元素,** 注意元素并不对应到属性或方法中(下同) ** 31 | 32 | 元素 | 类型 | 说明 33 | ----- | ----- | ----- 34 | name | string | **Required** 每一个类型都需要为其命名,如果能唯一是比较安全,但并不强制,该属性需要定义于attribute中 35 | fields | array | **Required** 包含的字段内容,以fields()方法体现. 36 | resolveField | callback | **function($value, $args, $context, GraphQL\Type\Definition\ResolveInfo $info)** 对于字段的解释,比如fields定义user属性,则对应的解释方法为resolveUserField() ,$value指定为type定义的类型实例 37 | 38 | ### Query ### 39 | 40 | GraphQLQuery,GraphQLMutation继承了GraphQLField,元素结构是一致的,想做对于一些复用性的Field,可以继承它. 41 | Graphql的每次查询都需要对应到一个GraphQLQuery对象 42 | 43 | GraphQLField的主要元素 44 | 45 | 元素 | 类型 | 说明 46 | ----- | ----- | ----- 47 | type | ObjectType | 对应的查询类型,单一类型用GraphQL::type指定,列表用Type::listOf(GraphQL::type) 48 | args | array | 查询需要使用的参数,其中每个参数按照Field定义 49 | resolve | callback | **function($value, $args, $context, GraphQL\Type\Definition\ResolveInfo $info)**,$value为root数据,$args即查询参数,$context上下文,为Yii的yii\web\Application对象,$info为查询解析对象,一般在这个方法中处理根对象 50 | 51 | ### Mutation ### 52 | 53 | 与GraphQLQuery是非常相像,参考说明. 54 | 55 | ### 简化处理 ### 56 | 57 | 简化了Field的声明,字段可直接使用type 58 | 59 | ```php 60 | 标准方式 61 | 'id'=>[ 62 | 'type'=>type::id(), 63 | ], 64 | 简化写法 65 | 'id'=>type::id() 66 | ``` 67 | 68 | ### 在YII使用 ### 69 | 70 | 本组件采用trait的方式在Component组件中被引入,组件宿主建议的方式是Module 71 | ```php 72 | class Module extends Module{ 73 | use GraphQLModuleTrait; 74 | } 75 | ``` 76 | Yii config file: 77 | ```php 78 | 'components'=>[ 79 | 'graphql'=>[ 80 | 'class'=>'xxx\xxxx\module' 81 | //主graphql协议配置 82 | 'schema' => [ 83 | 'query' => [ 84 | 'user' => 'app\graphql\query\UsersQuery' 85 | ], 86 | 'mutation' => [ 87 | 'login' 88 | ], 89 | //if you use sample query except query contain interface,fragment,not need set 90 | //the key must same as your class definded 91 | 'types'=>[ 92 | 'Story'=>'yiiunit\extensions\graphql\objects\types\StoryType' 93 | ], 94 | ] 95 | ], 96 | ]; 97 | ``` 98 | 采用的是actions的方法进行集成 99 | ```php 100 | class xxxController extends Controller{ 101 | function actions() 102 | { 103 | return [ 104 | 'index'=>[ 105 | 'class'=>'yii\graphql\GraphQLAction' 106 | ] 107 | ]; 108 | } 109 | } 110 | ``` 111 | 112 | 在采用动态解析的情况下,如果不想定义types时,schema的写法有讲究.可采用Type::class,避免采用Key方式,也方便直接通过IDE导航到对应的类下 113 | ```php 114 | 'type'=>GraphQL::type(UserType::class) 115 | ``` 116 | 117 | ### 输入验证 118 | 119 | 针对mutation的数据提交,提供了验证支持. 120 | 除了graphql基于的验证外,还可以使用yii的验证,目前为针对输入参数验证.直接在mutation定义中增加rules方法, 121 | 与Yii Model的使用方式是一致的. 122 | ```php 123 | public function rules() 124 | { 125 | return [ 126 | ['password','boolean'] 127 | ]; 128 | } 129 | 130 | ``` 131 | 132 | ### 授权验证 133 | 134 | 由于graphql查询是可以采用组合方式,如一次查询合并了两个query,而这两个query具有不同的授权约束,因此在graph中需要采用自定义的验证方式。 135 | 我把这多次查询查询称为graphql actions;当所有的graphql actions条件都满足配置时,才通过授权检查。 136 | 137 | #### 授权 138 | 在controller的行为方法中设置采用的授权方法,例子如下, 139 | ```php 140 | function behaviors() 141 | { 142 | return [ 143 | 'authenticator'=>[ 144 | 'class'=>'yii\graphql\filter\auth\CompositeAuth', 145 | 'authMethods'=>[ 146 | \yii\filters\auth\QueryParamAuth::className(), 147 | ], 148 | 'except'=>['hello'] 149 | ], 150 | ]; 151 | } 152 | ``` 153 | 如果要支持IntrospectionQueryr的授权,相应的graphql action为"__schema" 154 | 155 | ### Demo ### 156 | 157 | #### 创建基于graphql协议的查询 #### 158 | 159 | 每次查询对应一个GraphQLQuery文件, 160 | ```php 161 | 162 | class UserQuery extends GraphQLQuery 163 | { 164 | public function type() 165 | { 166 | return GraphQL::type(UserType::class); 167 | } 168 | 169 | public function args() 170 | { 171 | return [ 172 | 'id'=>[ 173 | 'type'=>Type::nonNull(Type::id()) 174 | ], 175 | ]; 176 | } 177 | 178 | public function resolve($value, $args, $context, ResolveInfo $info) 179 | { 180 | return DataSource::findUser($args['id']); 181 | } 182 | 183 | 184 | } 185 | ``` 186 | 187 | 根据查询协议定义类型文件 188 | ```php 189 | 190 | class UserType extends GraphQLType 191 | { 192 | protected $attributes = [ 193 | 'name'=>'user', 194 | 'description'=>'user is user' 195 | ]; 196 | 197 | public function fields() 198 | { 199 | $result = [ 200 | 'id' => ['type'=>Type::id()], 201 | 'email' => Types::email(), 202 | 'email2' => Types::email(), 203 | 'photo' => [ 204 | 'type' => GraphQL::type(ImageType::class), 205 | 'description' => 'User photo URL', 206 | 'args' => [ 207 | 'size' => Type::nonNull(GraphQL::type(ImageSizeEnumType::class)), 208 | ] 209 | ], 210 | 'firstName' => [ 211 | 'type' => Type::string(), 212 | ], 213 | 'lastName' => [ 214 | 'type' => Type::string(), 215 | ], 216 | 'lastStoryPosted' => GraphQL::type(StoryType::class), 217 | 'fieldWithError' => [ 218 | 'type' => Type::string(), 219 | 'resolve' => function() { 220 | throw new \Exception("This is error field"); 221 | } 222 | ] 223 | ]; 224 | return $result; 225 | } 226 | 227 | public function resolvePhotoField(User $user,$args){ 228 | return DataSource::getUserPhoto($user->id, $args['size']); 229 | } 230 | 231 | public function resolveIdField(User $user, $args) 232 | { 233 | return $user->id.'test'; 234 | } 235 | 236 | public function resolveEmail2Field(User $user, $args) 237 | { 238 | return $user->email2.'test'; 239 | } 240 | 241 | 242 | } 243 | ``` 244 | 245 | #### 查询实例 #### 246 | 247 | ```php 248 | 'hello' => " 249 | query hello{hello} 250 | ", 251 | 252 | 'singleObject' => " 253 | query user { 254 | user(id:\"2\") { 255 | id 256 | email 257 | email2 258 | photo(size:ICON){ 259 | id 260 | url 261 | } 262 | firstName 263 | lastName 264 | 265 | } 266 | } 267 | ", 268 | 'multiObject' => " 269 | query multiObject { 270 | user(id: \"2\") { 271 | id 272 | email 273 | photo(size:ICON){ 274 | id 275 | url 276 | } 277 | } 278 | stories(after: \"1\") { 279 | id 280 | author{ 281 | id 282 | } 283 | body 284 | } 285 | } 286 | ", 287 | 'updateObject' => " 288 | mutation updateUserPwd{ 289 | updateUserPwd(id: \"1001\", password: \"123456\") { 290 | id, 291 | username 292 | } 293 | } 294 | " 295 | ``` 296 | 297 | ### 深入了解 ### 298 | 299 | 有必要了解一些graphql-php的相关知识,这部分git上的文档相对还少些,需要对源码的阅读.下面列出重点 300 | 301 | #### DocumentNode (语法解构) #### 302 | 303 | ``` 304 | array definitions 305 | array OperationDefinitionNode 306 | string kind 307 | array NameNode 308 | string kind 309 | string value 310 | ``` 311 | 312 | ### Future 313 | * ActiveRecord generate tool for generating query and mutation class. 314 | * 对于graphql的一些特殊语法,像参数语法,内置指令语法还未进行测试 315 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./tests 12 | 13 | 14 | 15 | 16 | framework/ 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/ErrorFormatter.php: -------------------------------------------------------------------------------- 1 | getPrevious(); 20 | if ($previous) { 21 | Yii::$app->getErrorHandler()->logException($previous); 22 | if ($previous instanceof ValidatorException) { 23 | return $previous->formatErrors; 24 | } 25 | if ($previous instanceof HttpException) { 26 | return ['code' => $previous->statusCode, 'message' => $previous->getMessage()]; 27 | } else { 28 | return ['code' => $previous->getCode(), 'message' => $previous->getMessage()]; 29 | } 30 | } else { 31 | Yii::error($e->getMessage(), get_class($e)); 32 | } 33 | 34 | return FormattedError::createFromException($e, YII_DEBUG); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/GraphQL.php: -------------------------------------------------------------------------------- 1 | typeResolution) { 69 | $this->typeResolution = new TypeResolution(); 70 | } 71 | return $this->typeResolution; 72 | } 73 | 74 | /** 75 | * Receive schema data and incorporate configuration information 76 | * 77 | * array format: 78 | * $schema = new [ 79 | * 'query'=>[ 80 | * //the key is alias,mutation,types and so on 81 | * 'hello'=>HelloQuery::class 82 | * ], 83 | * 'mutation'=>[], 84 | * 'types'=>[], 85 | * ]; 86 | * @param null|array $schema 配置数组,该数组会导入对象自身的配置持久化下来 87 | */ 88 | public function schema($schema = null) 89 | { 90 | if (is_array($schema)) { 91 | $schemaQuery = ArrayHelper::getValue($schema, 'query', []); 92 | $schemaMutation = ArrayHelper::getValue($schema, 'mutation', []); 93 | $schemaTypes = ArrayHelper::getValue($schema, 'types', []); 94 | $this->queries += $schemaQuery; 95 | $this->mutations += $schemaMutation; 96 | $this->types += $schemaTypes; 97 | $this->getTypeResolution()->setAlias($schemaTypes); 98 | } 99 | } 100 | 101 | /** 102 | * GraphQl Schema is built according to input. Especially, 103 | * due to the need of Module and Controller in the process of building ObjectType, 104 | * the execution position of the method is restricted to a certain extent. 105 | * @param Schema|array $schema schema data 106 | * @return Schema 107 | */ 108 | public function buildSchema($schema = null) 109 | { 110 | if ($schema instanceof Schema) { 111 | return $schema; 112 | } 113 | if ($schema === null) { 114 | list($schemaQuery, $schemaMutation, $schemaTypes) = [$this->queries, $this->mutations, $this->types]; 115 | } else { 116 | list($schemaQuery, $schemaMutation, $schemaTypes) = $schema; 117 | } 118 | $types = []; 119 | if (sizeof($schemaTypes)) { 120 | foreach ($schemaTypes as $name => $type) { 121 | $types[] = $this->getTypeResolution()->parseType($name, true); 122 | } 123 | } 124 | //graqhql的validator要求query必须有 125 | $query = $this->getTypeResolution()->objectType($schemaQuery, [ 126 | 'name' => 'Query' 127 | ]); 128 | 129 | $mutation = null; 130 | if (!empty($schemaMutation)) { 131 | $mutation = $this->getTypeResolution()->objectType($schemaMutation, [ 132 | 'name' => 'Mutation' 133 | ]); 134 | } 135 | 136 | $this->getTypeResolution()->initTypes([$query, $mutation], $schema == null); 137 | 138 | $result = new Schema([ 139 | 'query' => $query, 140 | 'mutation' => $mutation, 141 | 'types' => $types, 142 | 'typeLoader' => function ($name) { 143 | return $this->getTypeResolution()->parseType($name, true); 144 | } 145 | ]); 146 | return $result; 147 | } 148 | 149 | 150 | /** 151 | * query access 152 | * @param $requestString 153 | * @param null $rootValue 154 | * @param null $contextValue 155 | * @param null $variableValues 156 | * @param string $operationName 157 | * @return array|Error\InvariantViolation 158 | */ 159 | public function query($requestString, $rootValue = null, $contextValue = null, $variableValues = null, $operationName = '') 160 | { 161 | $sl = $this->parseRequestQuery($requestString); 162 | if ($sl === true) { 163 | $sl = [$this->queries, $this->mutations, $this->types]; 164 | } 165 | $schema = $this->buildSchema($sl); 166 | 167 | $val = $this->execute($schema, $rootValue, $contextValue, $variableValues, $operationName); 168 | return $this->getResult($val); 169 | } 170 | 171 | /** 172 | * @param $executeResult 173 | * @return array|Promise 174 | */ 175 | public function getResult($executeResult) 176 | { 177 | if ($executeResult instanceof ExecutionResult) { 178 | if ($this->errorFormatter) { 179 | $executeResult->setErrorFormatter($this->errorFormatter); 180 | } 181 | return $this->parseExecutionResult($executeResult); 182 | } elseif ($executeResult instanceof Promise) { 183 | return $executeResult->then(function (ExecutionResult $executionResult) { 184 | if ($this->errorFormatter) { 185 | $executionResult->setErrorFormatter($this->errorFormatter); 186 | } 187 | return $this->parseExecutionResult($executionResult); 188 | }); 189 | } else { 190 | throw new Error\InvariantViolation("Unexpected execution result"); 191 | } 192 | } 193 | 194 | private function parseExecutionResult(ExecutionResult $executeResult) 195 | { 196 | if (empty($executeResult->errors) || empty($this->errorFormatter)) { 197 | return $executeResult->toArray(); 198 | } 199 | $result = []; 200 | 201 | if (null !== $executeResult->data) { 202 | $result['data'] = $executeResult->data; 203 | } 204 | 205 | if (!empty($executeResult->errors)) { 206 | $result['errors'] = []; 207 | foreach ($executeResult->errors as $er) { 208 | $fn = $this->errorFormatter; 209 | $fr = $fn($er); 210 | if (isset($fr['message'])) { 211 | $result['errors'][] = $fr; 212 | } else { 213 | $result['errors'] += $fr; 214 | } 215 | } 216 | // $result['errors'] = array_map($executeResult->errorFormatter, $executeResult->errors); 217 | } 218 | 219 | if (!empty($executeResult->extensions)) { 220 | $result['extensions'] = (array)$executeResult->extensions; 221 | } 222 | 223 | return $result; 224 | } 225 | 226 | /** 227 | * Executing the query according to schema, this method needs to be executed after the schema is generated 228 | * @param $schema 229 | * @param $rootValue 230 | * @param $contextValue 231 | * @param $variableValues 232 | * @param $operationName 233 | * @return ExecutionResult|Promise 234 | */ 235 | public function execute($schema, $rootValue, $contextValue, $variableValues, $operationName) 236 | { 237 | try { 238 | /** @var QueryComplexity $queryComplexity */ 239 | $queryComplexity = DocumentValidator::getRule('QueryComplexity'); 240 | $queryComplexity->setRawVariableValues($variableValues); 241 | 242 | $validationErrors = DocumentValidator::validate($schema, $this->currentDocument); 243 | 244 | if (!empty($validationErrors)) { 245 | return new ExecutionResult(null, $validationErrors); 246 | } 247 | return Executor::execute($schema, $this->currentDocument, $rootValue, $contextValue, $variableValues, $operationName); 248 | } catch (Error\Error $e) { 249 | return new ExecutionResult(null, [$e]); 250 | } finally { 251 | $this->currentDocument = null; 252 | } 253 | } 254 | 255 | /** 256 | * 将查询请求转换为可以转换为schema方法的数组 257 | * @param $requestString 258 | * @return array|bool 数组元素为0:query,1:mutation,2:types,当返回true时,表示为IntrospectionQuery 259 | */ 260 | public function parseRequestQuery($requestString) 261 | { 262 | $source = new Source($requestString ?: '', 'GraphQL request'); 263 | $this->currentDocument = Parser::parse($source); 264 | $queryTypes = []; 265 | $mutation = []; 266 | $types = []; 267 | $isAll = false; 268 | foreach ($this->currentDocument->definitions as $definition) { 269 | if ($definition instanceof OperationDefinitionNode) { 270 | $selections = $definition->selectionSet->selections; 271 | foreach ($selections as $selection) { 272 | $node = $selection->name; 273 | if ($node instanceof NameNode) { 274 | if ($definition->operation == 'query') { 275 | if ($definition->name && $definition->name->value == 'IntrospectionQuery') { 276 | $isAll = true; 277 | break 2; 278 | } 279 | if (isset($this->queries[$node->value])) { 280 | $queryTypes[$node->value] = $this->queries[$node->value]; 281 | } 282 | if (isset($this->types[$node->value])) { 283 | $types[$node->value] = $this->types[$node->value]; 284 | } 285 | } elseif ($definition->operation == 'mutation') { 286 | if (isset($this->mutations[$node->value])) { 287 | $mutation[$node->value] = $this->mutations[$node->value]; 288 | } 289 | } 290 | } 291 | } 292 | } 293 | } 294 | return $isAll ?: [$queryTypes, $mutation, $types]; 295 | } 296 | 297 | /** 298 | * Type manager access 299 | * @param string|Type $name 300 | * @param bool $byAlias if use alias 301 | * @return mixed 302 | */ 303 | public static function type($name, $byAlias = false) 304 | { 305 | /** @var GraphQLModuleTrait $module */ 306 | $module = Yii::$app->controller ? Yii::$app->controller->module : Yii::$app->getModule('graphql'); 307 | $gql = $module->getGraphQL(); 308 | 309 | return $gql->getTypeResolution()->parseType($name, $byAlias); 310 | } 311 | 312 | /** 313 | * @param $class 314 | * @param null $name 315 | */ 316 | public function addType($class, $name = null) 317 | { 318 | $name = $this->getTypeName($class, $name); 319 | $this->types[$name] = $class; 320 | } 321 | 322 | /** 323 | * 324 | * @param $class 325 | * @param null $name 326 | * @return null 327 | * @throws InvalidConfigException 328 | */ 329 | protected function getTypeName($class, $name = null) 330 | { 331 | if ($name) { 332 | return $name; 333 | } 334 | 335 | $type = is_object($class) ? $class : Yii::createObject($class); 336 | return $type->name; 337 | } 338 | 339 | /** 340 | * set error formatter 341 | * @param Callable $errorFormatter 342 | */ 343 | public function setErrorFormatter(Callable $errorFormatter) 344 | { 345 | $this->errorFormatter = $errorFormatter; 346 | } 347 | 348 | /** 349 | * validate the schema. 350 | * 351 | * when initial the schema,the types parameter must not passed. 352 | * 353 | * @param Schema $schema 354 | */ 355 | public function assertValid($schema) 356 | { 357 | //the type come from the TypeResolution. 358 | foreach ($this->types as $name => $type) { 359 | $schema->getType($name); 360 | } 361 | $schema->assertValid(); 362 | } 363 | } -------------------------------------------------------------------------------- /src/GraphQLAction.php: -------------------------------------------------------------------------------- 1 | ['class'=>'yii\graphql\GraphQLAction'] 26 | * ] 27 | * } 28 | * ``` 29 | * @package yii\graphql 30 | */ 31 | class GraphQLAction extends Action 32 | { 33 | const INTROSPECTIONQUERY = '__schema'; 34 | /** 35 | * @var GraphQL 36 | */ 37 | private $graphQL; 38 | private $schemaArray; 39 | private $query; 40 | private $variables; 41 | private $operationName; 42 | /** 43 | * @var array child graphql actions 44 | */ 45 | private $authActions = []; 46 | /** 47 | * @var callable a PHP callable that will be called when running an action to determine 48 | * if the current user has the permission to execute the action. If not set, the access 49 | * check will not be performed. The signature of the callable should be as follows, 50 | * 51 | * ```php 52 | * function ($actionName) { 53 | * 54 | * // If null, it means no specific model (e.g. IndexAction) 55 | * } 56 | * ``` 57 | */ 58 | public $checkAccess; 59 | /** 60 | * @var bool whether use Schema validation , and it is recommended only in the development environment 61 | */ 62 | public $enableSchemaAssertValid = YII_ENV_DEV; 63 | 64 | public function init() 65 | { 66 | parent::init(); 67 | 68 | $request = Yii::$app->getRequest(); 69 | if ($request->isGet) { 70 | $this->query = $request->get('query'); 71 | $this->variables = $request->get('variables'); 72 | $this->operationName = $request->get('operationName'); 73 | } else { 74 | $body = $request->getBodyParams(); 75 | if (empty($body)) { 76 | //取原始文件当查询,这时只支持如其他方式下的query的节点的查询 77 | $this->query = $request->getRawBody(); 78 | } else { 79 | if (!empty($body['operations'])) { 80 | $serverRequest = ServerRequestFactory::fromGlobals(); 81 | $uploadMiddleware = new UploadMiddleware(); 82 | $serverRequest = $uploadMiddleware->processRequest($serverRequest); 83 | $parsedBody = $serverRequest->getParsedBody(); 84 | 85 | $this->query = $parsedBody['query'] ?? $parsedBody; 86 | $this->variables = $parsedBody['variables'] ?? []; 87 | $this->operationName = $parsedBody['operationName'] ?? null; 88 | } else { 89 | $this->query = $body['query'] ?? $body; 90 | $this->variables = $body['variables'] ?? []; 91 | $this->operationName = $body['operationName'] ?? null; 92 | } 93 | } 94 | } 95 | if (empty($this->query)) { 96 | throw new InvalidParamException('invalid query,query document not found'); 97 | } 98 | if (is_string($this->variables)) { 99 | $this->variables = json_decode($this->variables, true); 100 | } 101 | 102 | /** @var GraphQLModuleTrait $module */ 103 | $module = $this->controller->module; 104 | $this->graphQL = $module->getGraphQL(); 105 | 106 | $this->schemaArray = $this->graphQL->parseRequestQuery($this->query); 107 | } 108 | 109 | /** 110 | * 返回本次查询的所有graphql action,如果本次查询为introspection时,则为查询的 111 | * @return array 112 | */ 113 | public function getGraphQLActions() 114 | { 115 | if ($this->schemaArray === true) { 116 | return [self::INTROSPECTIONQUERY => 'true']; 117 | } 118 | $ret = array_merge($this->schemaArray[0], $this->schemaArray[1]); 119 | if (!$this->authActions) { 120 | //init 121 | $this->authActions = array_merge($this->schemaArray[0], $this->schemaArray[1]); 122 | } 123 | return $ret; 124 | } 125 | 126 | /** 127 | * remove action that no need check access 128 | * @param $key 129 | */ 130 | public function removeGraphQlAction($key) 131 | { 132 | unset($this->authActions[$key]); 133 | } 134 | 135 | /** 136 | * @return array 137 | */ 138 | public function run() 139 | { 140 | Yii::$app->response->format = Response::FORMAT_JSON; 141 | if ($this->authActions && $this->checkAccess) { 142 | foreach ($this->authActions as $childAction => $class) { 143 | $fn = $this->checkAccess; 144 | $fn($childAction); 145 | } 146 | } 147 | $schema = $this->graphQL->buildSchema($this->schemaArray === true ? null : $this->schemaArray); 148 | //TODO the graphql-php's valid too strict,the lazy load has can't pass when execute mutation(must has query node) 149 | // if ($this->enableSchemaAssertValid) { 150 | // $this->graphQL->assertValid($schema); 151 | // } 152 | $val = $this->graphQL->execute($schema, null, Yii::$app, $this->variables, $this->operationName); 153 | $result = $this->graphQL->getResult($val); 154 | return $result; 155 | } 156 | } -------------------------------------------------------------------------------- /src/GraphQLModuleTrait.php: -------------------------------------------------------------------------------- 1 | [ 27 | * 'query' => [ 28 | * 'user' => 'App\GraphQL\Query\UsersQuery' 29 | * ], 30 | * 'mutation' => [ 31 | * 32 | * ], 33 | * 'types'=>[ 34 | * 'user'=>'app\modules\graph\type\UserType' 35 | * ], 36 | * ] 37 | * 38 | * @var array 39 | */ 40 | public $schema = []; 41 | 42 | /** 43 | * @var GraphQL the Graph handle 44 | */ 45 | private $graphQL; 46 | 47 | /** 48 | * @var callable if don't set error formatter,it will use php-graphql default 49 | * @see \GraphQL\Executor\ExecutionResult 50 | */ 51 | public $errorFormatter; 52 | 53 | /** 54 | * get graphql handler 55 | * @return GraphQL 56 | */ 57 | public function getGraphQL() 58 | { 59 | if ($this->graphQL == null) { 60 | $this->graphQL = new GraphQL(); 61 | $this->graphQL->schema($this->schema); 62 | if ($this->errorFormatter) { 63 | $this->graphQL->setErrorFormatter($this->errorFormatter); 64 | } else { 65 | $this->graphQL->setErrorFormatter(['yii\graphql\ErrorFormatter', 'formatError']); 66 | } 67 | } 68 | return $this->graphQL; 69 | } 70 | } -------------------------------------------------------------------------------- /src/TypeResolution.php: -------------------------------------------------------------------------------- 1 | alias for className to map the type 29 | */ 30 | private $alias = []; 31 | /** 32 | * @var Type[] 33 | */ 34 | private $typeMap = []; 35 | 36 | /** 37 | * @var array 38 | */ 39 | private $implementations = []; 40 | 41 | /** 42 | * EagerResolution constructor. 43 | */ 44 | public function __construct() 45 | { 46 | 47 | } 48 | 49 | /** 50 | * set type config 51 | * @param $config 52 | */ 53 | public function setAlias($config) 54 | { 55 | 56 | $this->alias = $config; 57 | } 58 | 59 | /** 60 | * @param Type[] $graphTypes 61 | * @param bool $needIntrospection if need IntrospectionQuery set true 62 | */ 63 | public function initTypes($graphTypes, $needIntrospection = false) 64 | { 65 | $typeMap = []; 66 | if ($needIntrospection) { 67 | $graphTypes[] = Introspection::_schema(); 68 | } 69 | foreach ($graphTypes as $type) { 70 | $typeMap = Utils\TypeInfo::extractTypes($type, $typeMap); 71 | } 72 | $this->typeMap = $typeMap + Type::getStandardTypes(); 73 | 74 | // Keep track of all possible types for abstract types 75 | foreach ($this->typeMap as $typeName => $type) { 76 | if ($type instanceof ObjectType) { 77 | foreach ($type->getInterfaces() as $iface) { 78 | $this->implementations[$iface->name][] = $type; 79 | } 80 | } 81 | } 82 | } 83 | 84 | /** 85 | * transform single type 86 | * @param Type $type 87 | */ 88 | protected function transformType($type) 89 | { 90 | if ($type instanceof ObjectType) { 91 | foreach ($type->getInterfaces() as $iface) { 92 | $this->implementations[$iface->name][] = $type; 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * @inheritdoc 99 | */ 100 | public function resolveType($name) 101 | { 102 | return isset($this->typeMap[$name]) ? $this->typeMap[$name] : $this->parseType($name, true); 103 | } 104 | 105 | /** 106 | * @inheritdoc 107 | */ 108 | public function resolvePossibleTypes(AbstractType $abstractType) 109 | { 110 | if (!isset($this->typeMap[$abstractType->name])) { 111 | return []; 112 | } 113 | 114 | if ($abstractType instanceof UnionType) { 115 | return $abstractType->getTypes(); 116 | } 117 | 118 | /** @var InterfaceType $abstractType */ 119 | Utils::invariant($abstractType instanceof InterfaceType); 120 | return isset($this->implementations[$abstractType->name]) ? $this->implementations[$abstractType->name] : []; 121 | } 122 | 123 | /** 124 | * @return Type[] 125 | */ 126 | public function getTypeMap() 127 | { 128 | return $this->typeMap; 129 | } 130 | 131 | /** 132 | * Returns serializable schema representation suitable for GraphQL\Type\LazyResolution 133 | * 134 | * @return array 135 | */ 136 | public function getDescriptor() 137 | { 138 | $typeMap = []; 139 | $possibleTypesMap = []; 140 | foreach ($this->getTypeMap() as $type) { 141 | if ($type instanceof UnionType) { 142 | foreach ($type->getTypes() as $innerType) { 143 | $possibleTypesMap[$type->name][$innerType->name] = 1; 144 | } 145 | } else if ($type instanceof InterfaceType) { 146 | foreach ($this->implementations[$type->name] as $obj) { 147 | $possibleTypesMap[$type->name][$obj->name] = 1; 148 | } 149 | } 150 | $typeMap[$type->name] = 1; 151 | } 152 | return [ 153 | 'version' => '1.0', 154 | 'typeMap' => $typeMap, 155 | 'possibleTypeMap' => $possibleTypesMap 156 | ]; 157 | } 158 | 159 | /** 160 | * convert type declare to ObjectType instance 161 | * @param $type 162 | * @param array $opts 163 | * @return ObjectType|null 164 | */ 165 | public function objectType($type, $opts = []) 166 | { 167 | // If it's already an ObjectType, just update properties and return it. 168 | // If it's an array, assume it's an array of fields and build ObjectType 169 | // from it. Otherwise, build it from a string or an instance. 170 | $objectType = null; 171 | if ($type instanceof ObjectType) { 172 | $objectType = $type; 173 | foreach ($opts as $key => $value) { 174 | if (property_exists($objectType, $key)) { 175 | $objectType->{$key} = $value; 176 | } 177 | if (isset($objectType->config[$key])) { 178 | $objectType->config[$key] = $value; 179 | } 180 | } 181 | } elseif (is_array($type)) { 182 | $objectType = $this->buildObjectTypeFromFields($type, $opts); 183 | } else { 184 | $objectType = $this->buildObjectTypeFromClass($type, $opts); 185 | } 186 | 187 | return $objectType; 188 | } 189 | 190 | /** 191 | * build ObjectType from classname config 192 | * @param Object|array $type 能够转换为ObjectType的类实例或者类配置 193 | * @param array $opts 194 | * @return object 195 | * @throws InvalidConfigException 196 | */ 197 | protected function buildObjectTypeFromClass($type, $opts = []) 198 | { 199 | if (!is_object($type)) { 200 | $type = Yii::createObject($type); 201 | } 202 | 203 | foreach ($opts as $key => $value) { 204 | $type->{$key} = $value; 205 | } 206 | 207 | return $type->toType(); 208 | } 209 | 210 | /** 211 | * Configuring GraphQL ObjectType through the graphql declaration configuration 212 | * @param array $fields use standard graphql declare. 213 | * @param array $opts 214 | * @return ObjectType 215 | * @throws InvalidConfigException 216 | */ 217 | protected function buildObjectTypeFromFields($fields, $opts = []) 218 | { 219 | $typeFields = []; 220 | foreach ($fields as $name => $field) { 221 | if (is_string($field)) { 222 | $field = Yii::createObject($field); 223 | $name = is_numeric($name) ? $field->name : $name; 224 | $field['name'] = $name; 225 | $field = $field->toArray(); 226 | } else { 227 | $name = is_numeric($name) ? $field['name'] : $name; 228 | $field['name'] = $name; 229 | } 230 | $typeFields[$name] = $field; 231 | } 232 | 233 | return new ObjectType(array_merge([ 234 | 'fields' => $typeFields 235 | ], $opts)); 236 | } 237 | 238 | /** 239 | * get type by name,this method is use in Type definition class for TypeSystem 240 | * @param $name 241 | * @param bool $byAlias if use alias; 242 | * @return Type|null 243 | * @throws TypeNotFound | NotSupportedException 244 | */ 245 | public function parseType($name, $byAlias = false) 246 | { 247 | $class = $name; 248 | if (is_object($class)) { 249 | $name = get_class($class); 250 | } 251 | 252 | if ($byAlias && isset($this->alias[$name])) { 253 | $class = $this->alias[$name]; 254 | } elseif (!$byAlias && isset($this->alias[$name])) { 255 | $name = $this->alias[$name]; 256 | } 257 | 258 | if (isset($this->typeMap[$name])) { 259 | return $this->typeMap[$name]; 260 | } 261 | 262 | //class is string or not found; 263 | if (is_string($class)) { 264 | if (strpos($class, '\\') !== false && !class_exists($class)) { 265 | throw new TypeNotFound('Type ' . $name . ' not found.'); 266 | } 267 | 268 | } elseif (!is_object($class)) { 269 | throw new TypeNotFound('Type ' . $name . ' not found.'); 270 | } 271 | $type = $this->buildType($class); 272 | $this->alias[$type->name] = $class; 273 | $this->alias[$class] = $type->name; 274 | $this->typeMap[$type->name] = $type; 275 | $this->transformType($type); 276 | return $type; 277 | } 278 | 279 | /** 280 | * @param string $type type name 281 | * @param array $opts return Type's attribute set 282 | * @return ObjectType|Type|GraphQLField 283 | * @throws InvalidConfigException 284 | * @throws NotSupportedException 285 | */ 286 | protected function buildType($type) 287 | { 288 | if (!is_object($type)) { 289 | $type = Yii::createObject($type); 290 | } 291 | if ($type instanceof Type) { 292 | return $type; 293 | } elseif ($type instanceof GraphQLType) { 294 | //transfer ObjectType 295 | return $type->toType(); 296 | } elseif ($type instanceof GraphQLField) { 297 | //field is not need transfer to ObjectType,it just need config array 298 | return $type; 299 | } 300 | 301 | throw new NotSupportedException("Type:{$type} is not support translate to Graph Type"); 302 | } 303 | } -------------------------------------------------------------------------------- /src/base/GraphQLField.php: -------------------------------------------------------------------------------- 1 | attributes; 62 | $args = $this->args(); 63 | 64 | $attributes = array_merge([ 65 | 'args' => $args 66 | ], $attributes); 67 | 68 | $type = $this->type(); 69 | if (isset($type)) { 70 | if(!is_object($type)){ 71 | $type = GraphQL::type($type); 72 | } 73 | $attributes['type'] = $type; 74 | } 75 | 76 | $resolver = $this->getResolver(); 77 | if (isset($resolver)) { 78 | $attributes['resolve'] = $resolver; 79 | } 80 | 81 | return $attributes; 82 | } 83 | } -------------------------------------------------------------------------------- /src/base/GraphQLInterfaceType.php: -------------------------------------------------------------------------------- 1 | getTypeResolver(); 38 | if (isset($resolver)) { 39 | $attributes['resolveType'] = $resolver; 40 | } 41 | 42 | return $attributes; 43 | } 44 | 45 | public function toType() 46 | { 47 | return new InterfaceType($this->toArray()); 48 | } 49 | } -------------------------------------------------------------------------------- /src/base/GraphQLModel.php: -------------------------------------------------------------------------------- 1 | attributes); 32 | } 33 | 34 | public function fields() 35 | { 36 | return $this->getAttributes(); 37 | } 38 | 39 | /** 40 | * Converts the object into an array. 41 | * 42 | * @param array $fields the fields that the output array should contain. Fields not specified 43 | * in [[fields()]] will be ignored. If this parameter is empty, all fields as specified in [[fields()]] will be returned. 44 | * @param array $expand the additional fields that the output array should contain. 45 | * Fields not specified in [[extraFields()]] will be ignored. If this parameter is empty, no extra fields 46 | * will be returned. 47 | * @param boolean $recursive whether to recursively return array representation of embedded objects. 48 | * @return array the array representation of the object 49 | */ 50 | public function toArray(array $fields = [], array $expand = [], $recursive = true) 51 | { 52 | return $this->getAttributes(); 53 | } 54 | 55 | public function __get($name) 56 | { 57 | $attributes = $this->getAttributes(); 58 | return isset($attributes[$name]) ? $attributes[$name] : null; 59 | } 60 | 61 | public function __isset($name) 62 | { 63 | $attributes = $this->getAttributes(); 64 | return isset($attributes[$name]); 65 | } 66 | 67 | 68 | public function __set($name, $value) 69 | { 70 | $this->attributes[$name] = $value; 71 | } 72 | 73 | public function __unset($name) 74 | { 75 | unset($this->attributes[$name]); 76 | } 77 | 78 | 79 | } -------------------------------------------------------------------------------- /src/base/GraphQLMutation.php: -------------------------------------------------------------------------------- 1 | getAttributes(); 68 | } 69 | 70 | /** 71 | * the the field parsed,field could defined like: 72 | * //GraphQlType Node 73 | * 'fieldByType'=> GraphQl::types([typeName = 'className'|'ConfigName']); 74 | * 'field1ByTypeClassName'=> UserType::class 75 | * //GraphQLField Node 76 | * 'field2'=> HtmlField::class 77 | * 78 | * @return array 79 | */ 80 | public function getFields() 81 | { 82 | $fields = $this->fields(); 83 | $allFields = []; 84 | foreach ($fields as $name => $field) { 85 | //the field is a GraphQlType or GraphQLField 86 | if (is_string($field)) { 87 | $type = GraphQL::type($field); 88 | if ($type instanceof GraphQLField) { 89 | $field = $type->toArray(); 90 | $field['name'] = $name; 91 | } else { 92 | $field = [ 93 | 'name' => $name, 94 | 'type' => $type, 95 | ]; 96 | } 97 | } elseif ($field instanceof Type) { 98 | $field = [ 99 | 'name' => $name, 100 | 'type' => $field 101 | ]; 102 | 103 | } 104 | $resolver = $this->getFieldResolver($name, $field); 105 | if ($resolver) { 106 | $field['resolve'] = $resolver; 107 | } 108 | $allFields[$name] = $field; 109 | } 110 | 111 | return $allFields; 112 | } 113 | 114 | /** 115 | * Get the graphql office's description format,that will be used for create GraphQL Object Type. 116 | * @param $name 117 | * @param $except 118 | * @return array 119 | */ 120 | public function getAttributes($name = null, $except = null) 121 | { 122 | $attributes = array_merge($this->attributes, [ 123 | 'fields' => function () { 124 | return $this->getFields(); 125 | } 126 | ]); 127 | 128 | $interfaces = $this->interfaces(); 129 | if (sizeof($interfaces)) { 130 | $attributes['interfaces'] = $interfaces; 131 | } 132 | 133 | return $attributes; 134 | } 135 | 136 | /** 137 | * Convert this class to its ObjectType. 138 | * 139 | * @return ObjectType |InputObjectType 140 | */ 141 | public function toType() 142 | { 143 | if ($this->inputObject) { 144 | return new InputObjectType($this->toArray()); 145 | } 146 | return new ObjectType($this->toArray()); 147 | } 148 | } -------------------------------------------------------------------------------- /src/base/GraphQLUnionType.php: -------------------------------------------------------------------------------- 1 | attributes; 52 | 53 | $resolver = $this->getTypeResolver(); 54 | if (isset($resolver)) { 55 | $attributes['resolveType'] = $resolver; 56 | } 57 | $types = array_map(function ($item) { 58 | if (is_string($item)) { 59 | return GraphQL::type($item); 60 | } else { 61 | return $item; 62 | } 63 | }, static::types()); 64 | 65 | $attributes['types'] = $types; 66 | //TODO support $name and $except?? 67 | return $attributes; 68 | } 69 | 70 | public function toType() 71 | { 72 | return new UnionType($this->toArray()); 73 | } 74 | } -------------------------------------------------------------------------------- /src/exceptions/SchemaNotFound.php: -------------------------------------------------------------------------------- 1 | formName()} validate false", $code, $previous); 29 | $this->formatModelErrors($model); 30 | } 31 | 32 | /** 33 | * @param Model $model 34 | */ 35 | private function formatModelErrors($model) 36 | { 37 | foreach ($model->getErrors() as $field => $fielsErrors) { 38 | foreach ($fielsErrors as $error) { 39 | $this->formatErrors[] = ['code' => $field, 'message' => $error]; 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/filters/auth/CompositeAuth.php: -------------------------------------------------------------------------------- 1 | authMethods) ? true : parent::beforeAction($action); 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public function authenticate($user, $request, $response) 46 | { 47 | foreach ($this->authMethods as $i => $auth) { 48 | if (!$auth instanceof AuthInterface) { 49 | $this->authMethods[$i] = $auth = Yii::createObject($auth); 50 | if (!$auth instanceof AuthInterface) { 51 | throw new InvalidConfigException(get_class($auth) . ' must implement yii\filters\auth\AuthInterface'); 52 | } 53 | } 54 | 55 | $identity = $auth->authenticate($user, $request, $response); 56 | if ($identity !== null) { 57 | return $identity; 58 | } 59 | } 60 | 61 | return null; 62 | } 63 | 64 | /** 65 | * @inheritdoc 66 | */ 67 | public function challenge($response) 68 | { 69 | foreach ($this->authMethods as $method) { 70 | /** @var $method AuthInterface */ 71 | $method->challenge($response); 72 | } 73 | } 74 | 75 | protected function isActive($action) 76 | { 77 | if ($action instanceof GraphQLAction) { 78 | $maps = $action->getGraphQLActions(); 79 | 80 | if (empty($this->only)) { 81 | $onlyMatch = true; 82 | } else { 83 | $onlyMatch = true; 84 | foreach ($maps as $key => $value) { 85 | foreach ($this->only as $pattern) { 86 | if (fnmatch($pattern, $key)) { 87 | continue 2; 88 | } 89 | } 90 | $onlyMatch = false; 91 | break; 92 | } 93 | } 94 | 95 | $exceptMatch = true; 96 | foreach ($maps as $key => $value) { 97 | foreach ($this->except as $pattern) { 98 | if (fnmatch($pattern, $key)) { 99 | $action->removeGraphQlAction($key); 100 | continue 2; 101 | } 102 | } 103 | $exceptMatch = false; 104 | break; 105 | } 106 | return !$exceptMatch && $onlyMatch; 107 | } else { 108 | return parent::isActive($action); 109 | } 110 | } 111 | 112 | 113 | } -------------------------------------------------------------------------------- /src/traits/GlobalIdTrait.php: -------------------------------------------------------------------------------- 1 | decodeGlobalId($id); 39 | 40 | return $id; 41 | } 42 | 43 | /** 44 | * Get the decoded GraphQL Type. 45 | * 46 | * @param string $id 47 | * @return string 48 | */ 49 | public function decodeRelayType($id) 50 | { 51 | list($type, $id) = $this->decodeGlobalId($id); 52 | 53 | return $type; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/traits/ShouldValidate.php: -------------------------------------------------------------------------------- 1 | rules(); 29 | if (sizeof($rules)) { 30 | //索引1的为args参数. 31 | $args = ArrayHelper::getValue($arguments, 1, []); 32 | $val = DynamicModel::validateData($args, $rules); 33 | if ($error = $val->getFirstErrors()) { 34 | $msg = 'input argument(' . key($error) . ') has validate error:' . reset($error); 35 | throw new InvalidParamException($msg); 36 | } 37 | } 38 | 39 | return $resolver(...$arguments); 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/types/EmailType.php: -------------------------------------------------------------------------------- 1 | 'Email', 16 | 'serialize' => [$this, 'serialize'], 17 | 'parseValue' => [$this, 'parseValue'], 18 | 'parseLiteral' => [$this, 'parseLiteral'], 19 | ]; 20 | parent::__construct($config); 21 | } 22 | 23 | /** 24 | * Serializes an internal value to include in a response. 25 | * 26 | * @param string $value 27 | * @return string 28 | */ 29 | public function serialize($value) 30 | { 31 | // Assuming internal representation of email is always correct: 32 | return $value; 33 | 34 | // If it might be incorrect and you want to make sure that only correct values are included in response - 35 | // use following line instead: 36 | // return $this->parseValue($value); 37 | } 38 | 39 | /** 40 | * Parses an externally provided value (query variable) to use as an input 41 | * 42 | * @param mixed $value 43 | * @return mixed 44 | */ 45 | public function parseValue($value) 46 | { 47 | if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { 48 | throw new \UnexpectedValueException("Cannot represent value as email: " . Utils::printSafe($value)); 49 | } 50 | return $value; 51 | } 52 | 53 | /** 54 | * Parses an externally provided literal value (hardcoded in GraphQL query) to use as an input 55 | * 56 | * @param \GraphQL\Language\AST\Node $valueAST 57 | * @return string 58 | * @throws Error 59 | */ 60 | public function parseLiteral($valueAST) 61 | { 62 | // Note: throwing GraphQL\Error\Error vs \UnexpectedValueException to benefit from GraphQL 63 | // error location in query: 64 | if (!$valueAST instanceof StringValueNode) { 65 | throw new Error('Query error: Can only parse strings got: ' . $valueAST->kind, [$valueAST]); 66 | } 67 | if (!filter_var($valueAST->value, FILTER_VALIDATE_EMAIL)) { 68 | throw new Error("Not a valid email", [$valueAST]); 69 | } 70 | return $valueAST->value; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/types/PageInfoType.php: -------------------------------------------------------------------------------- 1 | 'PageInfo', 21 | 'description' => 'Information about pagination in a connection', 22 | 'fields' => [ 23 | 'endCursor' => [ 24 | 'type' => Type::string(), 25 | 'description' => 'When paginating forwards, the cursor to continue.' 26 | ], 27 | 'hasNextPage' => [ 28 | 'type' => Type::boolean(), 29 | 'description' => 'When paginating forwards, are there more items?', 30 | ], 31 | 'hasPreviousPage' => [ 32 | 'type' => Type::boolean(), 33 | 'description' => 'When paginating backwards, are there more items?', 34 | ], 35 | 'startCursor' => [ 36 | 'type' => Type::string(), 37 | 'description' => 'When paginating backwards, the cursor to continue.', 38 | ], 39 | ], 40 | ]; 41 | parent::__construct($config); 42 | } 43 | } -------------------------------------------------------------------------------- /src/types/PaginationType.php: -------------------------------------------------------------------------------- 1 | 'Pagination', 21 | 'description' => '', 22 | 'fields' => [ 23 | 'first' => [ 24 | 'type' => Type::int(), 25 | 'description' => 'Returns the first n elements from the list.' 26 | ], 27 | 'after' => [ 28 | 'type' => Type::string(), 29 | 'description' => 'Returns the elements in the list that come after the specified global ID.' 30 | ], 31 | 'last' => [ 32 | 'type' => Type::int(), 33 | 'description' => 'Returns the last n elements from the list..' 34 | ], 35 | 'before' => [ 36 | 'type' => Type::string(), 37 | 'description' => 'Returns the elements in the list that come before the specified global ID.' 38 | ], 39 | ], 40 | ]; 41 | 42 | parent::__construct($config); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/types/SimpleExpressionType.php: -------------------------------------------------------------------------------- 1 | '=', 24 | 'gt' => '>', 25 | 'lt' => '<', 26 | 'gte' => '>=', 27 | 'lte' => '<=', 28 | ]; 29 | 30 | protected $attributes = [ 31 | 'name' => 'FieldCondition', 32 | 'description' => 'simple query expression,backend parse it to prepare to query data source', 33 | ]; 34 | 35 | public function fields() 36 | { 37 | return [ 38 | 'gt' => [ 39 | 'type' => Type::int(), 40 | 'description' => 'great than', 41 | ], 42 | 'gte' => [ 43 | 'type' => Type::int(), 44 | 'description' => 'great than or equals', 45 | ], 46 | 'lt' => [ 47 | 'type' => Type::int(), 48 | 'description' => 'less than', 49 | ], 50 | 'lte' => [ 51 | 'type' => Type::int(), 52 | 'description' => 'less than or equals', 53 | ], 54 | 'eq' => [ 55 | 'type' => Type::string(), 56 | 'description' => 'equals', 57 | ], 58 | 'in' => [ 59 | 'type' => Type::listOf(Type::int()), 60 | 'description' => 'value in list', 61 | ], 62 | ]; 63 | } 64 | 65 | public static function toQueryCondition($source) 66 | { 67 | $ret = []; 68 | foreach ($source as $key => $value) { 69 | if (is_scalar($value)) { 70 | $ret[$key] = $value; 71 | } elseif (is_array($value)) { 72 | $opExp = key($value); 73 | $op = self::$operatorMap[$opExp]??$opExp; 74 | $ret[] = [$op, $key, $value[$opExp]]; 75 | } 76 | } 77 | return $ret; 78 | } 79 | } -------------------------------------------------------------------------------- /src/types/UrlType.php: -------------------------------------------------------------------------------- 1 | parseValue($value); 33 | } 34 | 35 | /** 36 | * Parses an externally provided value (query variable) to use as an input 37 | * 38 | * @param mixed $value 39 | * @return mixed 40 | */ 41 | public function parseValue($value) 42 | { 43 | if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_URL)) { // quite naive, but after all this is example 44 | throw new \UnexpectedValueException("Cannot represent value as URL: " . Utils::printSafe($value)); 45 | } 46 | return $value; 47 | } 48 | 49 | /** 50 | * Parses an externally provided literal value to use as an input (e.g. in Query AST) 51 | * 52 | * @param $ast Node 53 | * @return null|string 54 | * @throws Error 55 | */ 56 | public function parseLiteral($ast) 57 | { 58 | // Note: throwing GraphQL\Error\Error vs \UnexpectedValueException to benefit from GraphQL 59 | // error location in query: 60 | if (!($ast instanceof StringValueNode)) { 61 | throw new Error('Query error: Can only parse strings got: ' . $ast->kind, [$ast]); 62 | } 63 | if (!is_string($ast->value) || !filter_var($ast->value, FILTER_VALIDATE_URL)) { 64 | throw new Error('Query error: Not a valid URL', [$ast]); 65 | } 66 | return $ast->value; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/DefaultController.php: -------------------------------------------------------------------------------- 1 | [ 25 | 'class' => 'yii\graphql\GraphQLAction', 26 | ] 27 | ]; 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /tests/GraphQLActionTest.php: -------------------------------------------------------------------------------- 1 | mockWebApplication(); 27 | $this->controller = new DefaultController('default', \Yii::$app->getModule('graphql')); 28 | } 29 | 30 | 31 | function testAction() 32 | { 33 | $_GET = [ 34 | 'query' => $this->queries['hello'], 35 | ]; 36 | $controller = $this->controller; 37 | $ret = $controller->runAction('index'); 38 | $this->assertNotEmpty($ret); 39 | } 40 | 41 | function testRunError() 42 | { 43 | $_GET = [ 44 | 'query' => 'query error{error}', 45 | ]; 46 | $controller = $this->controller; 47 | $action = $controller->createAction('index'); 48 | $action->enableSchemaAssertValid = false; 49 | $ret = $action->runWithParams([]); 50 | $this->assertNotEmpty($ret); 51 | $this->assertArrayHasKey('errors', $ret); 52 | } 53 | 54 | function testAuthBehavior() 55 | { 56 | $_GET = [ 57 | 'query' => $this->queries['hello'], 58 | 'access-token' => 'testtoken', 59 | ]; 60 | $controller = $this->controller; 61 | $controller->attachBehavior('authenticator', [ 62 | 'class' => QueryParamAuth::className() 63 | ]); 64 | $ret = $controller->runAction('index'); 65 | $this->assertNotEmpty($ret); 66 | } 67 | 68 | function testAuthBehaviorExcept() 69 | { 70 | $_GET = [ 71 | 'query' => $this->queries['hello'], 72 | ]; 73 | $controller = $this->controller; 74 | $controller->attachBehavior('authenticator', [ 75 | 'class' => CompositeAuth::className(), 76 | 'authMethods' => [ 77 | \yii\filters\auth\QueryParamAuth::className(), 78 | ], 79 | 'except' => ['hello'], 80 | ]); 81 | $ret = $controller->runAction('index'); 82 | $this->assertNotEmpty($ret); 83 | } 84 | 85 | function testIntrospectionQuery() 86 | { 87 | $_GET = [ 88 | 'query' => $this->queries['introspectionQuery'], 89 | ]; 90 | $controller = $this->controller; 91 | $controller->attachBehavior('authenticator', [ 92 | 'class' => CompositeAuth::className(), 93 | 'authMethods' => [ 94 | \yii\filters\auth\QueryParamAuth::className(), 95 | ], 96 | 'except' => ['__schema'], 97 | ]); 98 | $ret = $controller->runAction('index'); 99 | $this->assertNotEmpty($ret); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/GraphQLTest.php: -------------------------------------------------------------------------------- 1 | mockWebApplication(); 29 | $this->graphql = \Yii::$app->getModule('graphql')->getGraphQL(); 30 | } 31 | 32 | 33 | /** 34 | * Test schema default 35 | * 36 | * @test 37 | */ 38 | public function testSchema() 39 | { 40 | $schema = $this->graphql->buildSchema(); 41 | 42 | $this->assertGraphQLSchema($schema); 43 | $this->assertGraphQLSchemaHasQuery($schema, 'stories'); 44 | $this->assertGraphQLSchemaHasMutation($schema, 'updateUserPwd'); 45 | $this->assertArrayHasKey('user', $schema->getTypeMap()); 46 | } 47 | 48 | /** 49 | * Test schema with object 50 | * 51 | * @test 52 | */ 53 | public function testSchemaWithSchemaObject() 54 | { 55 | $schemaObject = new Schema([ 56 | 'query' => new ObjectType([ 57 | 'name' => 'Query' 58 | ]), 59 | 'mutation' => new ObjectType([ 60 | 'name' => 'Mutation' 61 | ]), 62 | 'types' => [] 63 | ]); 64 | $schema = $this->graphql->buildSchema($schemaObject); 65 | 66 | $this->assertGraphQLSchema($schema); 67 | $this->assertEquals($schemaObject, $schema); 68 | } 69 | 70 | /** 71 | * Test type 72 | * 73 | * @test 74 | */ 75 | public function testType() 76 | { 77 | $type = GraphQL::type(ExampleType::class); 78 | $this->assertInstanceOf(\GraphQL\Type\Definition\ObjectType::class, $type); 79 | 80 | $typeOther = GraphQL::type('example', true); 81 | $this->assertTrue($type === $typeOther); 82 | 83 | } 84 | 85 | public function testUnionType() 86 | { 87 | $type = GraphQL::type(ResultItemType::className()); 88 | $this->assertInstanceOf(\GraphQL\Type\Definition\UnionType::class, $type); 89 | } 90 | 91 | public function testParseRequestQuery() 92 | { 93 | $query = $this->queries['multiQuery']; 94 | $ret = $this->graphql->parseRequestQuery($query); 95 | $this->assertNotEmpty($ret); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/GraphqlMutationTest.php: -------------------------------------------------------------------------------- 1 | mockWebApplication(); 24 | $this->graphQL = \Yii::$app->getModule('graphql')->getGraphQL(); 25 | } 26 | 27 | public function testUpdateUserPwd(){ 28 | $query = "mutation updateUserPwd{ 29 | updateUserPwd(id: \"qsli@google.com\", password: \"123456\") { 30 | id, 31 | firstName 32 | } 33 | }"; 34 | $expect = [ 35 | 'updateUserPwd' => ['id' => "1", 'firstName' => "John"] 36 | ]; 37 | $result = $this->graphQL->query($query, null, \Yii::$app); 38 | $this->assertArrayHasKey('data', $result); 39 | $this->assertEquals($expect, $result['data']); 40 | } 41 | 42 | public function testValidator() 43 | { 44 | $query = " 45 | mutation updateUserPwd{ 46 | updateUserPwd(id: \"1001\",password: \"123456\") { 47 | id, 48 | email 49 | } 50 | } 51 | "; 52 | $result = $this->graphQL->query($query, null, \Yii::$app); 53 | $this->assertArrayHasKey('errors', $result); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/GraphqlQueryTest.php: -------------------------------------------------------------------------------- 1 | mockWebApplication(); 21 | $this->graphQL = \Yii::$app->getModule('graphql')->getGraphQL(); 22 | } 23 | 24 | /** 25 | * test if work 26 | */ 27 | public function testQueryValid() 28 | { 29 | $result = $this->graphQL->query($this->queries['hello']); 30 | $this->assertArrayHasKey('data', $result); 31 | $this->assertArrayNotHasKey('errors', $result); 32 | } 33 | 34 | /** 35 | * test sample object query 36 | */ 37 | public function testQueryWithSingleObject() 38 | { 39 | $result = $this->graphQL->query($this->queries['singleObject'], null, \Yii::$app); 40 | $this->assertArrayHasKey('data', $result); 41 | $this->assertArrayNotHasKey('errors', $result); 42 | } 43 | 44 | /** 45 | * test multi object in a query 46 | */ 47 | public function testQueryWithMultiObject() 48 | { 49 | $result = $this->graphQL->query($this->queries['multiObject'], null, \Yii::$app); 50 | $this->assertArrayHasKey('data', $result); 51 | $this->assertArrayNotHasKey('errors', $result); 52 | } 53 | 54 | public function testQueryWithUnion() 55 | { 56 | $query = ' 57 | query search 58 | { 59 | search(query:"a",limit:2,after:1,type:story){ 60 | nodes{ 61 | ... on story{ 62 | id 63 | } 64 | } 65 | } 66 | } 67 | '; 68 | $result = $this->graphQL->query($query, null, \Yii::$app); 69 | $this->assertArrayHasKey('data', $result); 70 | $this->assertArrayNotHasKey('errors', $result); 71 | } 72 | 73 | public function testQueryWithInterface() 74 | { 75 | $query = ' 76 | query { 77 | node(id:"1",type:"story"){ 78 | id, 79 | ... on story{ 80 | author{ 81 | id, 82 | email 83 | } 84 | } 85 | } 86 | } 87 | '; 88 | $result = $this->graphQL->query($query, null, \Yii::$app); 89 | $this->assertArrayHasKey('data', $result); 90 | $this->assertArrayNotHasKey('errors', $result); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Module.php: -------------------------------------------------------------------------------- 1 | destroyApplication(); 36 | } 37 | 38 | /** 39 | * Populates Yii::$app with a new application 40 | * The application will be destroyed on tearDown() automatically. 41 | * @param array $config The application configuration, if needed 42 | * @param string $appClass name of the application class to create 43 | */ 44 | protected function mockApplication($config = [], $appClass = '\yii\console\Application') 45 | { 46 | new $appClass(ArrayHelper::merge([ 47 | 'id' => 'testapp', 48 | 'basePath' => __DIR__, 49 | 'vendorPath' => dirname(__DIR__) . '/vendor', 50 | ], $config)); 51 | } 52 | 53 | protected function mockWebApplication($config = [], $appClass = '\yii\web\Application') 54 | { 55 | new $appClass(ArrayHelper::merge([ 56 | 'id' => 'testapp', 57 | 'basePath' => __DIR__, 58 | 'vendorPath' => dirname(__DIR__) . '/vendor', 59 | 'components' => [ 60 | 'request' => [ 61 | 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', 62 | 'scriptFile' => __DIR__ . '/index.php', 63 | 'scriptUrl' => '/index.php', 64 | ], 65 | 'graphQLCache' => [ 66 | 'class' => 'yii\caching\FileCache', 67 | 'cachePath' => '@runtime/graphql', 68 | 'directoryLevel' => 0, 69 | ], 70 | 'db'=>[ 71 | 'class' => 'yii\db\Connection', 72 | 'dsn' => 'mysql:host=localhost;dbname=test', 73 | 'username' => 'root', 74 | 'password' => '', 75 | 'charset' => 'utf8', 76 | ], 77 | 'user' => [ 78 | 'class' => 'yii\web\User', 79 | 'identityClass' => 'yiiunit\extensions\graphql\data\User' 80 | ], 81 | 82 | ], 83 | 'modules' => [ 84 | 'graphql' => [ 85 | 'class' => Module::class, 86 | 'schema' => [ 87 | 'query' => [ 88 | 'hello' => HelloQuery::class, 89 | 'user' => UserQuery::class, 90 | 'viewer' => ViewerQuery::class, 91 | 'stories' => StoryListQuery::class, 92 | 'lastStoryPosted' => LastStoryPostedQuery::class, 93 | 'search' => SearchQuery::className(), 94 | 'node' => NodeQuery::className(), 95 | ], 96 | 'mutation' => [ 97 | 'updateUserPwd' => UpdateUserPwdMutation::class 98 | ], 99 | 'types' => [ 100 | 'example' => ExampleType::class, 101 | 'story' => StoryType::class, 102 | // 'comment' => CommentType::class, 103 | // 'image' => ImageType::class, 104 | // 'imageSizeEnum' => ImageSizeEnumType::class, 105 | // 'ContentFormatEnum' => ContentFormatEnumType::class, 106 | ], 107 | ], 108 | ] 109 | ], 110 | 'bootstrap' => [ 111 | 'graphql' 112 | ], 113 | ], $config)); 114 | } 115 | 116 | /** 117 | * Destroys application in Yii::$app by setting it to null. 118 | */ 119 | protected function destroyApplication() 120 | { 121 | Yii::$app = null; 122 | Yii::$container = new Container(); 123 | } 124 | 125 | /** 126 | * Invokes object method, even if it is private or protected. 127 | * @param object $object object. 128 | * @param string $method method name. 129 | * @param array $args method arguments 130 | * @return mixed method result 131 | */ 132 | protected function invoke($object, $method, array $args = []) 133 | { 134 | $classReflection = new \ReflectionClass(get_class($object)); 135 | $methodReflection = $classReflection->getMethod($method); 136 | $methodReflection->setAccessible(true); 137 | $result = $methodReflection->invokeArgs($object, $args); 138 | $methodReflection->setAccessible(false); 139 | return $result; 140 | } 141 | 142 | protected function setUp() 143 | { 144 | parent::setUp(); 145 | $this->queries = include(__DIR__ . '/objects/queries.php'); 146 | DataSource::init(); 147 | } 148 | 149 | 150 | protected function assertGraphQLSchema($schema) 151 | { 152 | $this->assertInstanceOf('GraphQL\Type\Schema', $schema); 153 | } 154 | 155 | /** 156 | * @param Schema $schema 157 | * @param $key 158 | */ 159 | protected function assertGraphQLSchemaHasQuery($schema, $key) 160 | { 161 | //Query 162 | $query = $schema->getQueryType(); 163 | $queryFields = $query->getFields(); 164 | $this->assertArrayHasKey($key, $queryFields); 165 | 166 | $queryField = $queryFields[$key]; 167 | $queryListType = $queryField->getType(); 168 | $queryType = $queryListType->getWrappedType(); 169 | $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $queryField); 170 | $this->assertInstanceOf('GraphQL\Type\Definition\ListOfType', $queryListType); 171 | $this->assertInstanceOf('GraphQL\Type\Definition\ObjectType', $queryType); 172 | } 173 | 174 | /** 175 | * @param Schema $schema 176 | * @param $key 177 | */ 178 | protected function assertGraphQLSchemaHasMutation($schema, $key) 179 | { 180 | //Mutation 181 | $mutation = $schema->getMutationType(); 182 | $mutationFields = $mutation->getFields(); 183 | $this->assertArrayHasKey($key, $mutationFields); 184 | 185 | $mutationField = $mutationFields[$key]; 186 | $mutationType = $mutationField->getType(); 187 | $this->assertInstanceOf('GraphQL\Type\Definition\FieldDefinition', $mutationField); 188 | $this->assertInstanceOf('GraphQL\Type\Definition\ObjectType', $mutationType); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | new User([ 26 | 'id' => '1', 27 | 'email' => 'john@example.com', 28 | 'email2' => 'john2@example.com', 29 | 'firstName' => 'John', 30 | 'lastName' => 'Doe', 31 | 'password' => '123456', 32 | ]), 33 | '2' => new User([ 34 | 'id' => '2', 35 | 'email' => 'jane@example.com', 36 | 'email2' => 'john2@example.com', 37 | 'firstName' => 'Jane', 38 | 'lastName' => 'Doe', 39 | 'password' => '123456', 40 | ]), 41 | '3' => new User([ 42 | 'id' => '3', 43 | 'email' => 'john@example.com', 44 | 'email2' => 'john2@example.com', 45 | 'firstName' => 'John', 46 | 'lastName' => 'Doe', 47 | 'password' => '123456', 48 | ]), 49 | ]; 50 | 51 | self::$stories = [ 52 | '1' => new Story(['id' => '1', 'authorId' => '1', 'body' => '

GraphQL is awesome!

']), 53 | '2' => new Story(['id' => '2', 'authorId' => '1', 'body' => 'Test this']), 54 | '3' => new Story(['id' => '3', 'authorId' => '3', 'body' => "This\n
story\n
spans\n
newlines"]), 55 | ]; 56 | 57 | self::$storyLikes = [ 58 | '1' => ['1', '2', '3'], 59 | '2' => [], 60 | '3' => ['1'] 61 | ]; 62 | 63 | self::$comments = [ 64 | // thread #1: 65 | '100' => new Comment(['id' => '100', 'authorId' => '3', 'storyId' => '1', 'body' => 'Likes']), 66 | '110' => new Comment(['id' =>'110', 'authorId' =>'2', 'storyId' => '1', 'body' => 'Reply #1', 'parentId' => '100']), 67 | '111' => new Comment(['id' => '111', 'authorId' => '1', 'storyId' => '1', 'body' => 'Reply #1-1', 'parentId' => '110']), 68 | '112' => new Comment(['id' => '112', 'authorId' => '3', 'storyId' => '1', 'body' => 'Reply #1-2', 'parentId' => '110']), 69 | '113' => new Comment(['id' => '113', 'authorId' => '2', 'storyId' => '1', 'body' => 'Reply #1-3', 'parentId' => '110']), 70 | '114' => new Comment(['id' => '114', 'authorId' => '1', 'storyId' => '1', 'body' => 'Reply #1-4', 'parentId' => '110']), 71 | '115' => new Comment(['id' => '115', 'authorId' => '3', 'storyId' => '1', 'body' => 'Reply #1-5', 'parentId' => '110']), 72 | '116' => new Comment(['id' => '116', 'authorId' => '1', 'storyId' => '1', 'body' => 'Reply #1-6', 'parentId' => '110']), 73 | '117' => new Comment(['id' => '117', 'authorId' => '2', 'storyId' => '1', 'body' => 'Reply #1-7', 'parentId' => '110']), 74 | '120' => new Comment(['id' => '120', 'authorId' => '3', 'storyId' => '1', 'body' => 'Reply #2', 'parentId' => '100']), 75 | '130' => new Comment(['id' => '130', 'authorId' => '3', 'storyId' => '1', 'body' => 'Reply #3', 'parentId' => '100']), 76 | '200' => new Comment(['id' => '200', 'authorId' => '2', 'storyId' => '1', 'body' => 'Me2']), 77 | '300' => new Comment(['id' => '300', 'authorId' => '3', 'storyId' => '1', 'body' => 'U2']), 78 | 79 | # thread #2: 80 | '400' => new Comment(['id' => '400', 'authorId' => '2', 'storyId' => '2', 'body' => 'Me too']), 81 | '500' => new Comment(['id' => '500', 'authorId' => '2', 'storyId' => '2', 'body' => 'Nice!']), 82 | ]; 83 | 84 | self::$storyComments = [ 85 | '1' => ['100', '200', '300'], 86 | '2' => ['400', '500'] 87 | ]; 88 | 89 | self::$commentReplies = [ 90 | '100' => ['110', '120', '130'], 91 | '110' => ['111', '112', '113', '114', '115', '116', '117'], 92 | ]; 93 | 94 | self::$storyMentions = [ 95 | '1' => [ 96 | self::$users['2'] 97 | ], 98 | '2' => [ 99 | self::$stories['1'], 100 | self::$users['3'] 101 | ] 102 | ]; 103 | } 104 | 105 | public static function findUser($id) 106 | { 107 | return isset(self::$users[$id]) ? self::$users[$id] : null; 108 | } 109 | 110 | public static function findStory($id) 111 | { 112 | return isset(self::$stories[$id]) ? self::$stories[$id] : null; 113 | } 114 | 115 | public static function findComment($id) 116 | { 117 | return isset(self::$comments[$id]) ? self::$comments[$id] : null; 118 | } 119 | 120 | public static function findLastStoryFor($authorId) 121 | { 122 | $storiesFound = array_filter(self::$stories, function(Story $story) use ($authorId) { 123 | return $story->authorId == $authorId; 124 | }); 125 | return !empty($storiesFound) ? $storiesFound[count($storiesFound) - 1] : null; 126 | } 127 | 128 | public static function findLikes($storyId, $limit) 129 | { 130 | $likes = isset(self::$storyLikes[$storyId]) ? self::$storyLikes[$storyId] : []; 131 | $result = array_map( 132 | function($userId) { 133 | return self::$users[$userId]; 134 | }, 135 | $likes 136 | ); 137 | return array_slice($result, 0, $limit); 138 | } 139 | 140 | public static function isLikedBy($storyId, $userId) 141 | { 142 | $subscribers = isset(self::$storyLikes[$storyId]) ? self::$storyLikes[$storyId] : []; 143 | return in_array($userId, $subscribers); 144 | } 145 | 146 | public static function getUserPhoto($userId, $size) 147 | { 148 | return new Image([ 149 | 'id' => $userId, 150 | 'type' => Image::TYPE_USERPIC, 151 | 'size' => $size, 152 | 'width' => rand(100, 200), 153 | 'height' => rand(100, 200) 154 | ]); 155 | } 156 | 157 | public static function findLatestStory() 158 | { 159 | return array_pop(self::$stories); 160 | } 161 | 162 | public static function findStories($limit, $afterId = null) 163 | { 164 | $start = $afterId ? (int) array_search($afterId, array_keys(self::$stories)) + 1 : 0; 165 | return array_slice(array_values(self::$stories), $start, $limit); 166 | } 167 | 168 | public static function findComments($storyId, $limit = 5, $afterId = null) 169 | { 170 | $storyComments = isset(self::$storyComments[$storyId]) ? self::$storyComments[$storyId] : []; 171 | 172 | $start = isset($after) ? (int) array_search($afterId, $storyComments) + 1 : 0; 173 | $storyComments = array_slice($storyComments, $start, $limit); 174 | 175 | return array_map( 176 | function($commentId) { 177 | return self::$comments[$commentId]; 178 | }, 179 | $storyComments 180 | ); 181 | } 182 | 183 | public static function findReplies($commentId, $limit = 5, $afterId = null) 184 | { 185 | $commentReplies = isset(self::$commentReplies[$commentId]) ? self::$commentReplies[$commentId] : []; 186 | 187 | $start = isset($after) ? (int) array_search($afterId, $commentReplies) + 1: 0; 188 | $commentReplies = array_slice($commentReplies, $start, $limit); 189 | 190 | return array_map( 191 | function($replyId) { 192 | return self::$comments[$replyId]; 193 | }, 194 | $commentReplies 195 | ); 196 | } 197 | 198 | public static function countComments($storyId) 199 | { 200 | return isset(self::$storyComments[$storyId]) ? count(self::$storyComments[$storyId]) : 0; 201 | } 202 | 203 | public static function countReplies($commentId) 204 | { 205 | return isset(self::$commentReplies[$commentId]) ? count(self::$commentReplies[$commentId]) : 0; 206 | } 207 | 208 | public static function findStoryMentions($storyId) 209 | { 210 | return isset(self::$storyMentions[$storyId]) ? self::$storyMentions[$storyId] :[]; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/data/Image.php: -------------------------------------------------------------------------------- 1 | id; 41 | } 42 | 43 | public function getAuthKey() 44 | { 45 | // TODO: Implement getAuthKey() method. 46 | } 47 | 48 | public function validateAuthKey($authKey) 49 | { 50 | // TODO: Implement validateAuthKey() method. 51 | } 52 | 53 | 54 | } 55 | -------------------------------------------------------------------------------- /tests/objects/mutation/UpdateUserPwdMutation.php: -------------------------------------------------------------------------------- 1 | 'updateUserPwd' 23 | ]; 24 | 25 | public function type() 26 | { 27 | return GraphQL::type(UserType::class); 28 | } 29 | 30 | public function args() 31 | { 32 | return [ 33 | 'id' => [ 34 | 'name' => 'id', 35 | 'type' => Type::nonNull(Type::string()) 36 | ], 37 | 'password' => [ 38 | 'name' => 'password', 39 | 'type' => Type::nonNull(Type::string()) 40 | ] 41 | ]; 42 | } 43 | 44 | public function resolve($root, $args) 45 | { 46 | if ($args['id'] == 'qsli@google.com') { 47 | $args['id'] = 1; 48 | } 49 | $user = DataSource::findUser($args['id']); 50 | 51 | if(!$user) 52 | { 53 | return null; 54 | } 55 | 56 | $user->password = md5($args['password']); 57 | return $user; 58 | } 59 | 60 | public function rules() 61 | { 62 | return [ 63 | ['id', 'email'] 64 | ]; 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /tests/objects/queries.php: -------------------------------------------------------------------------------- 1 | " 6 | query IntrospectionQuery { 7 | __schema { 8 | queryType { name } 9 | mutationType { name } 10 | types { 11 | ...FullType 12 | } 13 | directives { 14 | name 15 | description 16 | args { 17 | ...InputValue 18 | } 19 | onOperation 20 | onFragment 21 | onField 22 | } 23 | } 24 | } 25 | 26 | fragment FullType on __Type { 27 | kind 28 | name 29 | description 30 | fields { 31 | name 32 | description 33 | args { 34 | ...InputValue 35 | } 36 | type { 37 | ...TypeRef 38 | } 39 | isDeprecated 40 | deprecationReason 41 | } 42 | inputFields { 43 | ...InputValue 44 | } 45 | interfaces { 46 | ...TypeRef 47 | } 48 | enumValues { 49 | name 50 | description 51 | isDeprecated 52 | deprecationReason 53 | } 54 | possibleTypes { 55 | ...TypeRef 56 | } 57 | } 58 | 59 | fragment InputValue on __InputValue { 60 | name 61 | description 62 | type { ...TypeRef } 63 | defaultValue 64 | } 65 | 66 | fragment TypeRef on __Type { 67 | kind 68 | name 69 | ofType { 70 | kind 71 | name 72 | ofType { 73 | kind 74 | name 75 | ofType { 76 | kind 77 | name 78 | } 79 | } 80 | } 81 | } 82 | ", 83 | 'hello' => " 84 | query hello{hello} 85 | ", 86 | 87 | 'singleObject' => " 88 | query user { 89 | user(id:\"2\") { 90 | id 91 | email 92 | email2 93 | photo(size:ICON){ 94 | id 95 | url 96 | } 97 | firstName 98 | lastName 99 | 100 | } 101 | } 102 | ", 103 | 'multiObject' => " 104 | query multiObject { 105 | user(id: \"2\") { 106 | id 107 | email 108 | photo(size:ICON){ 109 | id 110 | url 111 | } 112 | } 113 | stories(after: \"1\") { 114 | id 115 | author{ 116 | id 117 | } 118 | body 119 | } 120 | } 121 | ", 122 | 'repeatObject' => " 123 | query repeatObject { 124 | user(id: \"2\") { 125 | id 126 | email 127 | } 128 | stories(after: \"1\") { 129 | id 130 | author 131 | body 132 | } 133 | } 134 | ", 135 | 'userModel'=>" 136 | query userModel{ 137 | userModel(id: \"1001\") { 138 | id 139 | email 140 | } 141 | } 142 | ", 143 | 'updateObject' => " 144 | mutation updateUserPwd{ 145 | updateUserPwd(id: \"1001\", password: \"123456\") { 146 | id, 147 | username 148 | } 149 | } 150 | ", 151 | 'mutationValidate' => " 152 | mutation updateUserPwd{ 153 | updateUserPwd(id: \"1001\",password: \"123456\") { 154 | id, 155 | email 156 | } 157 | } 158 | ", 159 | 'multiQuery' => " 160 | query hello{hello} 161 | query userModel{ 162 | userModel(id: \"1001\") { 163 | id 164 | email 165 | } 166 | } 167 | ", 168 | ]; 169 | -------------------------------------------------------------------------------- /tests/objects/query/HelloQuery.php: -------------------------------------------------------------------------------- 1 | 'hello', 20 | ]; 21 | 22 | public function type() 23 | { 24 | return Type::string(); 25 | } 26 | 27 | 28 | protected function resolve($value, $args, $context, ResolveInfo $info) 29 | { 30 | return 'Your graphql-php endpoint is ready! Use GraphiQL to browse API'; 31 | } 32 | 33 | 34 | } -------------------------------------------------------------------------------- /tests/objects/query/LastStoryPostedQuery.php: -------------------------------------------------------------------------------- 1 | 'Returns last story posted for this blog', 22 | ]; 23 | 24 | public function type() 25 | { 26 | return GraphQL::type(StoryType::class); 27 | } 28 | 29 | protected function resolve($value, $args, $context, ResolveInfo $info) 30 | { 31 | return DataSource::findLatestStory(); 32 | } 33 | 34 | 35 | } -------------------------------------------------------------------------------- /tests/objects/query/NodeQuery.php: -------------------------------------------------------------------------------- 1 | Type::nonNull(Type::id()), 33 | 'type' => Type::string() 34 | ]; 35 | } 36 | 37 | public function resolve($value, $args, $context, ResolveInfo $info) 38 | { 39 | if ($args['type'] == 'user') { 40 | return DataSource::findUser($args['id']); 41 | } elseif ($args['type'] == 'story') { 42 | return DataSource::findStory($args['id']); 43 | } 44 | 45 | } 46 | 47 | 48 | } -------------------------------------------------------------------------------- /tests/objects/query/SearchQuery.php: -------------------------------------------------------------------------------- 1 | 'search', 27 | 'description' => 'search user or story', 28 | ]; 29 | 30 | public function args() 31 | { 32 | return [ 33 | 'limit' => Type::int(), 34 | 'after' => Type::int(), 35 | 'query' => Type::string(), 36 | 'type' => new EnumType(['name' => 't', 'values' => ['user' => ['value' => 'user'], 'story' => ['value' => 'story']]]), 37 | ]; 38 | } 39 | 40 | public function type() 41 | { 42 | return GraphQL::type(ResultItemConnectionType::className()); 43 | } 44 | 45 | protected function resolve($value, $args, Application $context, ResolveInfo $info) 46 | { 47 | $result = []; 48 | if ($args['type'] == 'user') { 49 | $result = [DataSource::findUser(1)]; 50 | } elseif ($args['type'] == 'story') { 51 | $result = DataSource::findStories($args['limit'], $args['after']); 52 | } 53 | return ['nodes' => $result]; 54 | } 55 | } -------------------------------------------------------------------------------- /tests/objects/query/StoryListQuery.php: -------------------------------------------------------------------------------- 1 | 'stories', 23 | 'description'=>'Returns subset of stories posted for this blog', 24 | ]; 25 | 26 | public function type() 27 | { 28 | return Type::listOf(GraphQL::type(StoryType::class)); 29 | } 30 | 31 | public function args() 32 | { 33 | return [ 34 | 'after'=>[ 35 | 'type'=>Type::id(), 36 | 'description'=>'Fetch stories listed after the story with this ID' 37 | ], 38 | 'limit' => [ 39 | 'type' => Type::int(), 40 | 'description' => 'Number of stories to be returned', 41 | 'defaultValue' => 10 42 | ] 43 | ]; 44 | } 45 | 46 | protected function resolve($value, $args, $context, ResolveInfo $info) 47 | { 48 | $args += ['after' => null]; 49 | return DataSource::findStories($args['limit'], $args['after']); 50 | } 51 | 52 | 53 | } -------------------------------------------------------------------------------- /tests/objects/query/UserQuery.php: -------------------------------------------------------------------------------- 1 | [ 32 | 'type'=>Type::nonNull(Type::id()) 33 | ], 34 | ]; 35 | } 36 | 37 | public function resolve($value, $args, $context, ResolveInfo $info) 38 | { 39 | return DataSource::findUser($args['id']); 40 | } 41 | 42 | 43 | } -------------------------------------------------------------------------------- /tests/objects/query/ViewerQuery.php: -------------------------------------------------------------------------------- 1 | 'Represents currently logged-in user (for the sake of example - simply returns user with id == 1)', 23 | ]; 24 | 25 | public function type() 26 | { 27 | return GraphQL::type(UserType::class); 28 | } 29 | 30 | protected function resolve($value, $args, Application $context, ResolveInfo $info) 31 | { 32 | return $context->user->getIdentity(); 33 | } 34 | } -------------------------------------------------------------------------------- /tests/objects/types/CommentType.php: -------------------------------------------------------------------------------- 1 | 'comment', 15 | 'description'=>'user make a view for story', 16 | ]; 17 | 18 | public function fields() 19 | { 20 | return [ 21 | 'id'=>Type::id(), 22 | 'author'=>GraphQL::type(UserType::class), 23 | 'parent'=>GraphQL::type(CommentType::class), 24 | 'isAnonymous'=>Type::boolean(), 25 | 'replies' => [ 26 | 'type' => Type::listOf(GraphQL::type(CommentType::class)), 27 | 'args' => [ 28 | 'after' => Type::int(), 29 | 'limit' => [ 30 | 'type' => Type::int(), 31 | 'defaultValue' => 5 32 | ] 33 | ] 34 | ], 35 | 'totalReplyCount' => Type::int(), 36 | ]; 37 | } 38 | 39 | public function resolveAuthorField(Comment $comment) 40 | { 41 | if ($comment->isAnonymous) { 42 | return null; 43 | } 44 | return DataSource::findUser($comment->authorId); 45 | } 46 | 47 | public function resolveParentField(Comment $comment) 48 | { 49 | if ($comment->parentId) { 50 | return DataSource::findComment($comment->parentId); 51 | } 52 | return null; 53 | } 54 | 55 | public function resolveRepliesField(Comment $comment, $args) 56 | { 57 | $args += ['after' => null]; 58 | return DataSource::findReplies($comment->id, $args['limit'], $args['after']); 59 | } 60 | 61 | public function resolveTotalReplyCountField(Comment $comment) 62 | { 63 | return DataSource::countReplies($comment->id); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/objects/types/ContentFormatEnumType.php: -------------------------------------------------------------------------------- 1 | 'ContentFormatEnum', 16 | 'values' => [self::FORMAT_TEXT, self::FORMAT_HTML] 17 | ]; 18 | parent::__construct($config); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/objects/types/ExampleType.php: -------------------------------------------------------------------------------- 1 | 'example', 23 | 'description'=>'user is user' 24 | ]; 25 | 26 | public function fields() 27 | { 28 | $result = [ 29 | 'id' => ['type'=>Type::id()], 30 | 'email' => GraphQL::type(EmailType::class), 31 | 'email2' => GraphQL::type(EmailType::class), 32 | 'photo' => [ 33 | 'type' => GraphQL::type(ImageType::class), 34 | 'description' => 'User photo URL', 35 | 'args' => [ 36 | 'size' => Type::nonNull(GraphQL::type(ImageSizeEnumType::class)), 37 | ] 38 | ], 39 | 'firstName' => [ 40 | 'type' => Type::string(), 41 | ], 42 | 'lastName' => [ 43 | 'type' => Type::string(), 44 | ], 45 | 'lastStoryPosted' => GraphQL::type(StoryType::class), 46 | 'fieldWithError' => [ 47 | 'type' => Type::string(), 48 | 'resolve' => function() { 49 | throw new \Exception("This is error field"); 50 | } 51 | ] 52 | ]; 53 | return $result; 54 | } 55 | 56 | public function resolvePhotoField(User $user,$args){ 57 | return DataSource::getUserPhoto($user->id, $args['size']); 58 | } 59 | 60 | public function resolveIdField(User $user, $args) 61 | { 62 | return $user->id.'test'; 63 | } 64 | 65 | public function resolveEmail2Field(User $user, $args) 66 | { 67 | return $user->email2.'test'; 68 | } 69 | 70 | 71 | } -------------------------------------------------------------------------------- /tests/objects/types/HtmlField.php: -------------------------------------------------------------------------------- 1 | 'a html tag', 22 | ]; 23 | 24 | public function type() 25 | { 26 | return Type::string(); 27 | } 28 | 29 | public function args() 30 | { 31 | return [ 32 | 'format' => [ 33 | 'type' => GraphQL::type(ContentFormatEnumType::class), 34 | 'defaultValue' => ContentFormatEnumType::FORMAT_HTML, 35 | ], 36 | 'maxLength' => Type::int(), 37 | ]; 38 | } 39 | 40 | public function resolve($root, $args,$context,ResolveInfo $info) 41 | { 42 | // $fields = $info->getFieldSelection($depth = 3); 43 | $html = $root->{$info->fieldName}; 44 | $text = strip_tags($html); 45 | 46 | if (!empty($args['maxLength'])) { 47 | $safeText = mb_substr($text, 0, $args['maxLength']); 48 | } else { 49 | $safeText = $text; 50 | } 51 | 52 | switch ($args['format']) { 53 | case ContentFormatEnumType::FORMAT_HTML: 54 | if ($safeText !== $text) { 55 | // Text was truncated, so just show what's safe: 56 | return nl2br($safeText); 57 | } else { 58 | return $html; 59 | } 60 | 61 | case ContentFormatEnumType::FORMAT_TEXT: 62 | default: 63 | return $safeText; 64 | } 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /tests/objects/types/ImageSizeEnumType.php: -------------------------------------------------------------------------------- 1 | 'imageSizeEnum', 14 | // Note: 'name' option is not needed in this form - it will be inferred from className 15 | 'values' => [ 16 | 'ICON' => [ 17 | 'value'=>Image::SIZE_ICON 18 | ], 19 | 'SMALL' => [ 20 | 'value'=> Image::SIZE_SMALL, 21 | ], 22 | 'MEDIUM' => [ 23 | 'value'=> Image::SIZE_MEDIUM, 24 | ], 25 | 'ORIGINAL' => [ 26 | 'value'=> Image::SIZE_ORIGINAL 27 | ], 28 | ] 29 | ]; 30 | 31 | parent::__construct($config); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/objects/types/ImageType.php: -------------------------------------------------------------------------------- 1 | 'image', 18 | 'description'=>'a common image type' 19 | ]; 20 | 21 | public function interfaces() 22 | { 23 | return [GraphQL::type(NodeType::className())]; 24 | } 25 | 26 | public function fields() 27 | { 28 | $result = [ 29 | 'id' => Type::id(), 30 | 'type' => new EnumType([ 31 | 'name' => 'ImageTypeEnum', 32 | 'values' => [ 33 | 'USERPIC' => 'userpic' 34 | ] 35 | ]), 36 | 'size' => ImageSizeEnumType::class, 37 | 'width' => Type::int(), 38 | 'height' => Type::int(), 39 | 'url' => [ 40 | 'type' => GraphQL::Type(UrlType::class), 41 | 'resolve' => [$this, 'resolveUrl'] 42 | ], 43 | 44 | // Just for the sake of example 45 | 'fieldWithError' => [ 46 | 'type' => Type::string(), 47 | 'resolve' => function() { 48 | throw new \Exception("Field with exception"); 49 | } 50 | ], 51 | 'nonNullFieldWithError' => [ 52 | 'type' => Type::nonNull(Type::string()), 53 | 'resolve' => function() { 54 | throw new \Exception("Non-null field with exception"); 55 | } 56 | ] 57 | ]; 58 | return $result; 59 | } 60 | 61 | public function resolveUrl(Image $value, $args, Application $context) 62 | { 63 | switch ($value->type) { 64 | case Image::TYPE_USERPIC: 65 | $path = "/images/user/{$value->id}-{$value->size}.jpg"; 66 | break; 67 | default: 68 | throw new \UnexpectedValueException("Unexpected image type: " . $value->type); 69 | } 70 | return $context->getHomeUrl() . $path; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/objects/types/NodeType.php: -------------------------------------------------------------------------------- 1 | 'node' 14 | ]; 15 | 16 | public function fields() 17 | { 18 | return [ 19 | 'id'=>Type::id() 20 | ]; 21 | } 22 | 23 | public function resolveType($object, $types) 24 | { 25 | if ($object instanceof UserType) { 26 | return GraphQL::type('user', true); 27 | } else if ($object instanceof ImageType) { 28 | return GraphQL::type('image', true); 29 | } else if ($object instanceof Story) { 30 | return GraphQL::type('story', true); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/objects/types/ResultItemConnectionType.php: -------------------------------------------------------------------------------- 1 | 'ResultItemConnection' 20 | ]; 21 | 22 | public function fields() 23 | { 24 | return [ 25 | 'nodes' => Type::listOf(GraphQL::type(ResultItemType::className())), 26 | ]; 27 | } 28 | } -------------------------------------------------------------------------------- /tests/objects/types/ResultItemType.php: -------------------------------------------------------------------------------- 1 | 'ResultItem', 21 | 'description' => 'result type' 22 | ]; 23 | 24 | public function types() 25 | { 26 | return [ 27 | StoryType::className(), 28 | UserType::className() 29 | ]; 30 | } 31 | 32 | protected function resolveType($value) 33 | { 34 | if ($value instanceof Story) { 35 | return GraphQL::type(StoryType::className()); 36 | } elseif ($value instanceof User) { 37 | return GraphQL::type(UserType::className()); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /tests/objects/types/StoryType.php: -------------------------------------------------------------------------------- 1 | 'story', 29 | 'description'=>'it is a story' 30 | ]; 31 | 32 | public function interfaces() 33 | { 34 | return [GraphQL::type(NodeType::className())]; 35 | } 36 | 37 | public function fields() 38 | { 39 | return [ 40 | 'id' => ['type'=>Type::id()], 41 | 'author' => GraphQL::type(UserType::class), 42 | // 'mentions' => Type::listOf(Types::mention()), 43 | 'totalCommentCount' => ['type'=>Type::int()], 44 | 'comments' => [ 45 | 'type' => Type::listOf(GraphQL::type(CommentType::class)), 46 | 'args' => [ 47 | 'after' => [ 48 | 'type' => Type::id(), 49 | 'description' => 'Load all comments listed after given comment ID' 50 | ], 51 | 'limit' => [ 52 | 'type' => Type::int(), 53 | 'defaultValue' => 5 54 | ] 55 | ] 56 | ], 57 | 'likes' => [ 58 | 'type' => Type::listOf(GraphQL::type(UserType::class)), 59 | 'args' => [ 60 | 'limit' => [ 61 | 'type' => Type::int(), 62 | 'description' => 'Limit the number of recent likes returned', 63 | 'defaultValue' => 5 64 | ] 65 | ] 66 | ], 67 | 'likedBy' => [ 68 | 'type' => Type::listOf(GraphQL::type(UserType::class)), 69 | ], 70 | 'affordances' => ['type'=>Type::listOf(new EnumType([ 71 | 'name' => 'StoryAffordancesEnum', 72 | 'values' => [ 73 | self::EDIT, 74 | self::DELETE, 75 | self::LIKE, 76 | self::UNLIKE, 77 | self::REPLY 78 | ] 79 | ]))], 80 | 'hasViewerLiked' => ['type'=>Type::boolean()], 81 | 82 | 'body'=>HtmlField::class, 83 | ]; 84 | } 85 | 86 | public function resolveAuthorField(Story $story) 87 | { 88 | return DataSource::findUser($story->authorId); 89 | } 90 | 91 | public function resolveAffordancesField(Story $story, $args, Application $context) 92 | { 93 | $viewer = $context->user->getIdentity(); 94 | $isViewer = $viewer === DataSource::findUser($story->authorId); 95 | $isLiked = DataSource::isLikedBy($story->id, $viewer->getId()); 96 | 97 | if ($isViewer) { 98 | $affordances[] = self::EDIT; 99 | $affordances[] = self::DELETE; 100 | } 101 | if ($isLiked) { 102 | $affordances[] = self::UNLIKE; 103 | } else { 104 | $affordances[] = self::LIKE; 105 | } 106 | return $affordances; 107 | } 108 | 109 | public function resolveHasViewerLikedField(Story $story, $args, Application $context) 110 | { 111 | return DataSource::isLikedBy($story->id, $context->getUser()->getId()); 112 | } 113 | 114 | public function resolveTotalCommentCountField(Story $story) 115 | { 116 | return DataSource::countComments($story->id); 117 | } 118 | 119 | public function resolveCommentsField(Story $story, $args) 120 | { 121 | $args += ['after' => null]; 122 | return DataSource::findComments($story->id, $args['limit'], $args['after']); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/objects/types/UserType.php: -------------------------------------------------------------------------------- 1 | 'user', 23 | 'description'=>'user is user' 24 | ]; 25 | 26 | public function interfaces() 27 | { 28 | return [GraphQL::type(NodeType::className())]; 29 | } 30 | 31 | public function fields() 32 | { 33 | $result = [ 34 | 'id' => ['type'=>Type::id()], 35 | 'email' => GraphQL::type(EmailType::class), 36 | 'email2' => GraphQL::type(EmailType::class), 37 | 'photo' => [ 38 | 'type' => GraphQL::type(ImageType::class), 39 | 'description' => 'User photo URL', 40 | 'args' => [ 41 | 'size' => Type::nonNull(GraphQL::type(ImageSizeEnumType::class)), 42 | ] 43 | ], 44 | 'firstName' => [ 45 | 'type' => Type::string(), 46 | ], 47 | 'lastName' => [ 48 | 'type' => Type::string(), 49 | ], 50 | 'lastStoryPosted' => GraphQL::type(StoryType::class), 51 | 'fieldWithError' => [ 52 | 'type' => Type::string(), 53 | 'resolve' => function() { 54 | throw new \Exception("This is error field"); 55 | } 56 | ] 57 | ]; 58 | return $result; 59 | } 60 | 61 | public function resolvePhotoField(User $user,$args){ 62 | return DataSource::getUserPhoto($user->id, $args['size']); 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /tests/types/SimpleExpressionTypeTest.php: -------------------------------------------------------------------------------- 1 | 1, 20 | 'name' => [ 21 | 'eq' => 'abc' 22 | ], 23 | 'count' => [ 24 | 'lt' => 1 25 | ], 26 | 'age' => [ 27 | 'gt' => 20 28 | ], 29 | ]; 30 | $expect = [ 31 | 'id' => 1, 32 | ['=', 'name', 'abc'], 33 | ['<', 'count', 1], 34 | ['>', 'age', 20], 35 | ]; 36 | 37 | $val = SimpleExpressionType::toQueryCondition($express); 38 | $this->assertEquals($expect, $val); 39 | } 40 | } 41 | --------------------------------------------------------------------------------