├── LICENSE ├── README.md ├── app.js ├── index.js ├── middleware.js ├── package.json ├── schema.js ├── server.js └── test ├── client.js ├── mutation.js ├── query.js └── relation.js /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 LeanCloud 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LeanCloud GraphQL 2 | 运行在云引擎上的第三方 GraphQL 支持,允许你用 GraphQL 查询 LeanCloud 云存储中的所有数据。 3 | 4 | 5 | 6 | - [部署到云引擎](#%E9%83%A8%E7%BD%B2%E5%88%B0%E4%BA%91%E5%BC%95%E6%93%8E) 7 | - [GraphQL](#graphql) 8 | - [获取数据](#%E8%8E%B7%E5%8F%96%E6%95%B0%E6%8D%AE) 9 | - [查询条件](#%E6%9F%A5%E8%AF%A2%E6%9D%A1%E4%BB%B6) 10 | * [equalTo](#equalto) 11 | * [exists](#exists) 12 | * [范围查询](#%E8%8C%83%E5%9B%B4%E6%9F%A5%E8%AF%A2) 13 | * [数组查询](#%E6%95%B0%E7%BB%84%E6%9F%A5%E8%AF%A2) 14 | * [组合查询](#%E7%BB%84%E5%90%88%E6%9F%A5%E8%AF%A2) 15 | - [关系查询](#%E5%85%B3%E7%B3%BB%E6%9F%A5%E8%AF%A2) 16 | * [Relation](#relation) 17 | * [Pointer](#pointer) 18 | * [查询条件](#%E6%9F%A5%E8%AF%A2%E6%9D%A1%E4%BB%B6-1) 19 | * [反向关系](#%E5%8F%8D%E5%90%91%E5%85%B3%E7%B3%BB) 20 | * [多级关系](#%E5%A4%9A%E7%BA%A7%E5%85%B3%E7%B3%BB) 21 | - [修改对象](#%E4%BF%AE%E6%94%B9%E5%AF%B9%E8%B1%A1) 22 | * [创建对象](#%E5%88%9B%E5%BB%BA%E5%AF%B9%E8%B1%A1) 23 | * [更新对象](#%E6%9B%B4%E6%96%B0%E5%AF%B9%E8%B1%A1) 24 | - [添加到现有项目](#%E6%B7%BB%E5%8A%A0%E5%88%B0%E7%8E%B0%E6%9C%89%E9%A1%B9%E7%9B%AE) 25 | * [作为中间件添加](#%E4%BD%9C%E4%B8%BA%E4%B8%AD%E9%97%B4%E4%BB%B6%E6%B7%BB%E5%8A%A0) 26 | * [获取 GraphQLSchema](#%E8%8E%B7%E5%8F%96-graphqlschema) 27 | 28 | 29 | 30 | ## 部署到云引擎 31 | 32 | 见 [LeanCloud 命令行工具](https://leancloud.cn/docs/leanengine_cli.html)。 33 | 34 | ## GraphQL 35 | 36 | [GraphQL](http://graphql.org/) 是 FaceBook 开源的一套查询语言,你可以用它定义数据的格式和获取方法(这就是 leancloud-graphql 做的工作,它会自动将你在 LeanCloud 的数据结构转换为 GraphQL 的 Schema),然后便可以在客户端以一种非常灵活的语法来获取数据,甚至也可以用它来创建和更新数据。 37 | 38 | 在使用 leancloud-graphql 之前,你可能需要先了解一下 [GraphQL 的语法](http://graphql.org/learn/queries/) ,下面我们不会过多地介绍 GraphQL 本身。这篇文章将使用 JavaScript SDK 文档中的 [示例数据结构](https://leancloud.cn/docs/leanstorage_guide-js.html#示例数据结构) 进行讲解。 39 | 40 | GraphQL 在客户端几乎不需要什么 SDK,你可以花几行代码封装一个工具函数: 41 | 42 | ```javascript 43 | function requestGraphQL(query) { 44 | return fetch('/', { 45 | method: 'POST', 46 | body: query 47 | }).then( res => { 48 | return res.json(); 49 | }).then( result => { 50 | return result.data; 51 | }); 52 | } 53 | ``` 54 | 55 | 我们也用 GraphiQL 提供了一个支持自动补全等功能的 GraphQL 控制台(本地调试时为 ),你可以在这里测试你的查询。 56 | 57 | 我们会应用客户端发来的 sessionToken,确保在用户的权限范围内进行查询。你可以从我们的 JavaScript SDK 上获取 sessionToken 并随着请求发送,修改 requestGraphQL: 58 | 59 | ```diff 60 | headers: { 61 | 'Content-Type': 'application/graphql', 62 | + 'X-LC-Session': AV.User.current() && AV.User.current().getSessionToken() 63 | }, 64 | ``` 65 | 66 | ## 获取数据 67 | 68 | 最简单的一个查询: 69 | 70 | ```graphql 71 | requestGraphQL(` 72 | query { 73 | Todo { 74 | title, priority 75 | } 76 | } 77 | `) 78 | ``` 79 | 80 | 默认会返回最多 100 条数据: 81 | 82 | ```javascript 83 | { 84 | Todo: [ 85 | {title: "紧急 Bug 修复", priority: 0}, 86 | {title: "打电话给 Peter",priority: 5}, 87 | {title: "还信用卡账单", priority: 10}, 88 | {title: "买酸奶", priority: 10}, 89 | {title: "团队会议", priority: 5} 90 | ] 91 | } 92 | ``` 93 | 94 | 你可以在此基础上添加排序、条数限制等选项: 95 | 96 | - `ascending` 按照指定字段升序。 97 | - `descending` 按照指定字段降序。 98 | - `limit` 条数限制。 99 | 100 | 例如我们按照优先级升序排序,取最重要的两个任务: 101 | 102 | ```graphql 103 | query { 104 | Todo(ascending: priority, limit: 2) { 105 | title, priority 106 | } 107 | } 108 | ``` 109 | 110 | 结果: 111 | 112 | ```javascript 113 | { 114 | Todo: [ 115 | {title: "紧急 Bug 修复", priority: 0}, 116 | {title: "打电话给 Peter",priority: 5} 117 | ] 118 | } 119 | ``` 120 | 121 | ## 查询条件 122 | 123 | 首先你可以按照 objectId 进行简单的查询: 124 | 125 | ```graphql 126 | query { 127 | Todo(objectId: "5853a0e5128fe1006b5ce449") { 128 | title, priority 129 | } 130 | } 131 | ``` 132 | 133 | 结果: 134 | 135 | ```javascript 136 | { 137 | Todo: [ 138 | {title: "还信用卡账单", priority: 10} 139 | ] 140 | } 141 | ``` 142 | 143 | ### equalTo 144 | 145 | 你也可以像 LeanCloud 的 SDK 一样使用多种查询条件: 146 | 147 | ```graphql 148 | query { 149 | Todo(equalTo: {title: "团队会议"}) { 150 | title 151 | } 152 | } 153 | ``` 154 | 155 | ### exists 156 | 157 | exists 可以用来查询存在或不存在某一字段的对象,例如我们查询存在 title 但不存在 content 的 Todo: 158 | 159 | ```graphql 160 | query { 161 | Todo(exists: {title: true, content: false}) { 162 | title, content 163 | } 164 | } 165 | ``` 166 | 167 | ### 范围查询 168 | 169 | ```graphql 170 | query { 171 | Todo(greaterThanOrEqualTo: {priority: 10}) { 172 | title, priority 173 | } 174 | } 175 | ``` 176 | 177 | 目前支持的查询包括: 178 | 179 | - `greaterThan` 约束指定列大于特定值。 180 | - `greaterThanOrEqualTo` 约束指定列大于等于特定值。 181 | - `lessThan` 约束指定列小于特定值。 182 | - `lessThanOrEqualTo` 约束指定列小于等于特定值。 183 | 184 | ### 数组查询 185 | 186 | ```graphql 187 | query { 188 | Todo(containedIn: {tags: ["Online"]}) { 189 | title, tags 190 | } 191 | } 192 | ``` 193 | 194 | 目前支持的数组查询包括: 195 | 196 | - `containedIn` 约束指定列中包含特定元素。 197 | - `containsAll` 约束指定列中包含所有元素。 198 | 199 | ### 组合查询 200 | 201 | 你可以将我们前面提到的所有查询条件组合在一起: 202 | 203 | ```graphql 204 | query { 205 | Todo(exists: {content: true}, ascending: priority, greaterThan: {priority: 5}) { 206 | title, content, priority 207 | } 208 | } 209 | ``` 210 | 211 | ## 关系查询 212 | 213 | ### Relation 214 | 215 | 如果对象的一个字段是 Relation,那么你就可以在 GraphQL 中将它展开,例如我们可以查询每个 TodoFolder 中包含的 Todo: 216 | 217 | ```graphql 218 | query { 219 | TodoFolder { 220 | name, containedTodos { 221 | title, priority 222 | } 223 | } 224 | } 225 | ``` 226 | 227 | 结果: 228 | 229 | ```javascript 230 | { 231 | TodoFolder: [{ 232 | name: "工作", 233 | containedTodos: [ 234 | {title: "紧急 Bug 修复", priority: 0}, 235 | {title: "打电话给 Peter", priority: 5}, 236 | {title: "团队会议", priority: 5} 237 | ] 238 | }, { 239 | name: "购物清单", 240 | containedTodos: [ 241 | {title: "买酸奶", priority: 10} 242 | ] 243 | }] 244 | } 245 | ``` 246 | 247 | ### Pointer 248 | 249 | 如果一个字段是 Pointer 你也可以将它展开,例如我们可以查询 Todo 的创建者(到用户表的指针): 250 | 251 | ```graphql 252 | query { 253 | Todo(limit: 1) { 254 | title, owner { 255 | username, email 256 | } 257 | } 258 | } 259 | ``` 260 | 261 | 结果: 262 | 263 | ```javascript 264 | { 265 | Todo: [ 266 | { 267 | title: "紧急 Bug 修复", 268 | owner: { 269 | username: "someone", 270 | email: "test@example.com" 271 | } 272 | } 273 | ] 274 | } 275 | ``` 276 | 277 | ### 查询条件 278 | 279 | 你也可以在关系查询上附加查询参数或查询条件: 280 | 281 | ```graphql 282 | query { 283 | TodoFolder { 284 | name, containedTodos(limit: 1, exists: {content: true}) { 285 | title, content 286 | } 287 | } 288 | } 289 | ``` 290 | 291 | 结果: 292 | 293 | ```javascript 294 | { 295 | TodoFolder: [{ 296 | name: "工作", 297 | containedTodos: [ 298 | {title: "团队会议", content: "BearyChat"} 299 | ] 300 | }, { 301 | name: "购物清单", 302 | containedTodos: [] 303 | }, { 304 | name: "someone", 305 | containedTodos: [ 306 | {title: "还信用卡账单", content: "2016 年 12 月"} 307 | ] 308 | }] 309 | } 310 | ``` 311 | 312 | 支持的参数和条件包括:`ascending`、`descending`、`limit`、`objectId`、`equalTo`、`exists`、`greaterThan`、`greaterThanOrEqualTo`、`lessThan`、`lessThanOrEqualTo`、`containedIn`、`containsAll`。 313 | 314 | ### 反向关系 315 | 316 | 在实现一对多关系时,我们经常会在「多」上面保存一个到「一」的指针,leancloud-graphql 会自动在「多」上面创建一个属性,用来表示反向关系。例如因为 Todo 的 owner 是一个指向 \_User 的 Pointer,所以 \_User 上会自动出现一个 `ownerOfTodo`: 317 | 318 | ```graphql 319 | query { 320 | _User { 321 | username, ownerOfTodo { 322 | title 323 | } 324 | } 325 | } 326 | ``` 327 | 328 | 这样我们便可以查到每个用户的 Todo: 329 | 330 | ```javascript 331 | { 332 | _User: [{ 333 | username: "someone", 334 | ownerOfTodo: [ 335 | {title: "紧急 Bug 修复"}, 336 | {title: "打电话给 Peter"}, 337 | {title: "还信用卡账单"}, 338 | {title: "买酸奶"} 339 | ] 340 | }] 341 | } 342 | ``` 343 | 344 | 你也可以在 Relation 上进行反向查询,例如查询每个 Todo 所属的 TodoFolder: 345 | 346 | ```graphql 347 | query { 348 | Todo { 349 | title, containedTodosOfTodoFolder { 350 | name 351 | } 352 | } 353 | } 354 | ``` 355 | 356 | 结果(省略了一部分): 357 | 358 | ```javascript 359 | { 360 | Todo: [{ 361 | title: "紧急 Bug 修复", 362 | containedTodosOfTodoFolder: [ 363 | {name: "工作"}, 364 | {name: "someone"} 365 | ] 366 | }, { 367 | title: "买酸奶", 368 | containedTodosOfTodoFolder: [ 369 | {name: "购物清单"}, 370 | {name: "someone"} 371 | ] 372 | }, { 373 | title: "团队会议", 374 | containedTodosOfTodoFolder: [ 375 | {name: "工作"} 376 | ] 377 | }] 378 | } 379 | ``` 380 | 381 | ### 多级关系 382 | 383 | 在 GraphQL 中你甚至可以进行多层级的关系查询: 384 | 385 | ```graphql 386 | query { 387 | TodoFolder { 388 | name, 389 | containedTodos { 390 | title, owner { 391 | username, email 392 | } 393 | } 394 | } 395 | } 396 | ``` 397 | 398 | 结果(省略了一部分): 399 | 400 | ```javascript 401 | { 402 | TodoFolder: [{ 403 | name: "工作", 404 | containedTodos: [{ 405 | title: "紧急 Bug 修复", 406 | owner: { 407 | username: "someone", 408 | email: "test@example.com" 409 | } 410 | }, // ... 411 | ] 412 | }, // ... 413 | ] 414 | } 415 | ``` 416 | 417 | ## 修改对象 418 | 419 | GraphQL 毕竟是一个数据查询语言,因此我们仅提供了非常有限的创建和更新对象的功能。 420 | 421 | ### 创建对象 422 | 423 | 你可以这样创建一个对象,并要求服务器返回 objectId、标题和优先级: 424 | 425 | ```graphql 426 | mutation { 427 | Todo(title: "思考巨石阵是如何修建的") { 428 | objectId, title, priority 429 | } 430 | } 431 | ``` 432 | 433 | 结果: 434 | 435 | ```javascript 436 | { 437 | Todo: { 438 | objectId: "5853adb7b123db006562f83b", 439 | title: "思考巨石阵是如何修建的", 440 | priority: 10 441 | } 442 | } 443 | ``` 444 | 445 | ### 更新对象 446 | 447 | 然后你可以用非常相似的语法来更新这个对象(当你提供了 objectId 便是更新对象): 448 | 449 | ```graphql 450 | mutation { 451 | Todo(objectId: "5853adb7b123db006562f83b", priority: 5) { 452 | title, priority 453 | } 454 | } 455 | ``` 456 | 457 | 结果: 458 | 459 | ```javascript 460 | { 461 | Todo: { 462 | title: "思考巨石阵是如何修建的", 463 | priority: 5 464 | } 465 | } 466 | ``` 467 | 468 | ## 添加到现有项目 469 | 470 | 如果要添加到现有项目,需要先将 leancloud-graphql 添加为依赖: 471 | 472 | npm install --save leancloud-graphql 473 | 474 | 请确保 Node.js 版本在 4.0 以上。 475 | 476 | ### 作为中间件添加 477 | 478 | leancloud-graphql 导出了一个 express 中间件,可以直接添加到现有的 express 项目上: 479 | 480 | ```javascript 481 | var leancloudGraphQL = require('leancloud-graphql').express; 482 | var app = express(); 483 | app.use('/graphql', leancloudGraphQL()); 484 | ``` 485 | 486 | leancloudGraphQL 有一些选项: 487 | 488 | - `graphiql` 开启调试控制台,默认 true. 489 | - `cors` 提供跨域支持,默认 true. 490 | - `pretty` 格式化返回的 JSON。 491 | 492 | 使用该中间件时请确保环境变量中有 `LEANCLOUD_` 系列的环境变量,即需要运行在云引擎上或用 `lean up` 启动。 493 | 494 | ### 获取 GraphQLSchema 495 | 496 | leancloud-graphql 默认导出了一个构建 GraphQLSchema 的函数: 497 | 498 | ```javascript 499 | var buildSchema = require('leancloud-graphql'); 500 | var {printSchema} = require('graphql'); 501 | 502 | buildSchema({ 503 | appId: process.env.LEANCLOUD_APP_ID, 504 | appKey: process.env.LEANCLOUD_APP_KEY, 505 | masterKey: process.env.LEANCLOUD_APP_MASTER_KEY 506 | }).then( schema => { 507 | console.log(printSchema(schema)); 508 | }); 509 | ``` 510 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const AV = require('leanengine'); 3 | 4 | const app = express(); 5 | 6 | app.use(AV.express()); 7 | app.use(require('./middleware')()); 8 | 9 | module.exports = app; 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./schema'); 2 | module.exports.express = require('./middleware'); 3 | -------------------------------------------------------------------------------- /middleware.js: -------------------------------------------------------------------------------- 1 | const {Router} = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const expressGraphql = require('express-graphql'); 4 | const _ = require('lodash'); 5 | 6 | const buildSchema = require('./schema'); 7 | 8 | module.exports = function({graphiql, cors, pretty} = {graphiql: true, cors: true}) { 9 | const router = new Router(); 10 | 11 | if (cors) { 12 | router.use(require('cors')({ 13 | allowedHeaders: ['X-LC-Session', 'Content-Type'], 14 | maxAge: 86400 15 | })); 16 | } 17 | 18 | router.use(bodyParser.text({type: ['text/plain', 'application/graphql']})); 19 | router.use(bodyParser.json()); 20 | 21 | const expressGraphqlReady = buildSchema({ 22 | appId: process.env.LEANCLOUD_APP_ID, 23 | appKey: process.env.LEANCLOUD_APP_KEY, 24 | masterKey: process.env.LEANCLOUD_APP_MASTER_KEY 25 | }).then( schema => { 26 | return expressGraphql({schema, graphiql, pretty}); 27 | }); 28 | 29 | router.use(function leancloudGraphQL(req, res, next) { 30 | req.authOptions = { 31 | sessionToken: req.headers['x-lc-session'] 32 | }; 33 | 34 | if (_.isString(req.body)) { 35 | req.body = { 36 | query: req.body 37 | }; 38 | } 39 | 40 | expressGraphqlReady.then( expressGraphqlMiddleware => { 41 | expressGraphqlMiddleware(req, res, next); 42 | }).catch(next); 43 | }); 44 | 45 | return router; 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leancloud-graphql", 3 | "version": "0.4.0", 4 | "description": "Third party GraphQL support for LeanCloud, running on LeanEngine", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/leancloud/leancloud-graphql" 9 | }, 10 | "scripts": { 11 | "test": "mocha" 12 | }, 13 | "engines": { 14 | "node": "6.x" 15 | }, 16 | "dependencies": { 17 | "body-parser": "^1.15.2", 18 | "cors": "^2.8.1", 19 | "express-graphql": "^0.6.7", 20 | "express": ">=4.0.0", 21 | "graphql": "^0.10.5", 22 | "leancloud-storage": ">=2.0.0", 23 | "leanengine": ">=2.0.0", 24 | "lodash": ">=4.0.0", 25 | "request-promise": "^4.1.1", 26 | "request": "^2.79.0" 27 | }, 28 | "devDependencies": { 29 | "chai": "^3.5.0", 30 | "supertest": "^2.0.1", 31 | "supertest-as-promised": "^4.0.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /schema.js: -------------------------------------------------------------------------------- 1 | const {GraphQLSchema, GraphQLObjectType, GraphQLScalarType} = require('graphql'); 2 | const {GraphQLEnumType, GraphQLInputObjectType, GraphQLList} = require('graphql') 3 | const {GraphQLID, GraphQLString, GraphQLBoolean, GraphQLInt, GraphQLFloat} = require('graphql'); 4 | const request = require('request-promise'); 5 | const AV = require('leancloud-storage'); 6 | const _ = require('lodash'); 7 | 8 | const debug = require('debug')('leancloud-graphql'); 9 | 10 | const LCDate = new GraphQLScalarType({ 11 | name: 'Date', 12 | serialize: (date) => { 13 | return date.toJSON(); 14 | } 15 | }); 16 | 17 | const LCObject = new GraphQLScalarType({ 18 | name: 'Object', 19 | serialize: (object) => { 20 | return object; 21 | } 22 | }); 23 | 24 | const LCArray = new GraphQLScalarType({ 25 | name: 'Array', 26 | serialize: (array) => { 27 | return array; 28 | } 29 | }); 30 | 31 | const LCFile = new GraphQLScalarType({ 32 | name: 'File', 33 | serialize: (file) => { 34 | return file; 35 | } 36 | }); 37 | 38 | const LCGeoPoint = new GraphQLScalarType({ 39 | name: 'GeoPoint', 40 | serialize: (point) => { 41 | return point; 42 | } 43 | }); 44 | 45 | const LCTypeMapping = { 46 | String: GraphQLString, 47 | Number: GraphQLFloat, 48 | Boolean: GraphQLBoolean, 49 | Date: LCDate, 50 | Object: LCObject, 51 | Array: LCArray, 52 | File: LCFile, 53 | GeoPoint: LCGeoPoint 54 | } 55 | 56 | module.exports = function buildSchema({appId, appKey, masterKey}) { 57 | return request({ 58 | url: 'https://api.leancloud.cn/1.1/schemas', 59 | json: true, 60 | headers: { 61 | 'X-LC-Id': appId, 62 | 'X-LC-Key': `${masterKey},master` 63 | } 64 | }).then( cloudSchemas => { 65 | return _.mapValues(cloudSchemas, (schema, className) => { 66 | return _.omitBy(schema, (definition, field) => { 67 | if (field.startsWith('__')) { 68 | console.error(`[leancloud-graphql] Ignored invalid GraphQL field name \`${className}.${field}\``); 69 | return true; 70 | } 71 | }); 72 | }); 73 | }).then( cloudSchemas => { 74 | const classes = _.mapValues(cloudSchemas, (schema, className) => { 75 | return AV.Object.extend(className); 76 | }); 77 | 78 | const classSchemasFieldsThunk = _.mapValues(cloudSchemas, (schema, className) => { 79 | return () => { 80 | const fields = _.mapValues(schema, (definition, field) => { 81 | if (definition.type === 'Relation') { 82 | return { 83 | type: new GraphQLList(classSchemas[definition.className]), 84 | args: querySchemas[classSchemas[definition.className]].args, 85 | resolve: (source, args, {authOptions}, info) => { 86 | return addArgumentsToQuery(source.relation(field).query(), args).find(authOptions); 87 | } 88 | } 89 | } else if (definition.type === 'Pointer') { 90 | return { 91 | type: classSchemas[definition.className], 92 | resolve: (source, args, {authOptions}, info) => { 93 | return new AV.Query(definition.className).get(source.get(field).id, authOptions); 94 | } 95 | } 96 | } else { 97 | return { 98 | type: LCTypeMapping[definition.type], 99 | resolve: (source, args, context, info) => { 100 | return source.get(field); 101 | } 102 | } 103 | } 104 | }); 105 | 106 | fields.objectId = { 107 | type: GraphQLID, 108 | resolve: (source, args, context, info) => { 109 | return source.id; 110 | } 111 | }; 112 | 113 | _.forEach(cloudSchemas, (schema, sourceClassName) => { 114 | _.forEach(schema, (definition, sourceField) => { 115 | if (definition.className === className) { 116 | debug(`Add reverse relationship: ${sourceField}Of${sourceClassName} on ${className}`); 117 | 118 | fields[`${sourceField}Of${sourceClassName}`] = { 119 | type: new GraphQLList(classSchemas[sourceClassName]), 120 | args: querySchemas[classSchemas[sourceClassName]].args, 121 | resolve: (source, args, {authOptions}, info) => { 122 | return addArgumentsToQuery(new AV.Query(sourceClassName), args).equalTo(sourceField, source).find(authOptions); 123 | } 124 | }; 125 | } 126 | }); 127 | }); 128 | 129 | return fields; 130 | } 131 | }); 132 | 133 | const classSchemas = _.mapValues(cloudSchemas, (schema, className) => { 134 | return new GraphQLObjectType({ 135 | name: className, 136 | fields: classSchemasFieldsThunk[className] 137 | }); 138 | }); 139 | 140 | const querySchemas = _.mapValues(cloudSchemas, (schema, className) => { 141 | const FieldsEnum = new GraphQLEnumType({ 142 | name: `${className}Fields`, 143 | values: _.mapValues(schema, (definition, field) => { 144 | return {value: field}; 145 | }) 146 | }); 147 | 148 | const createFieldsInputType = function(argName, innerType) { 149 | return new GraphQLInputObjectType({ 150 | name: `${className}${_.upperFirst(argName)}Argument`, 151 | fields: _.pickBy(_.mapValues(schema, (definition, field) => { 152 | if (innerType) { 153 | return {type: innerType}; 154 | } else if (LCTypeMapping[definition.type]) { 155 | return { 156 | type: LCTypeMapping[definition.type] 157 | }; 158 | } else { 159 | return null; 160 | } 161 | })) 162 | }); 163 | }; 164 | 165 | return { 166 | name: className, 167 | type: new GraphQLList(classSchemas[className]), 168 | args: { 169 | objectId: { 170 | type: GraphQLID 171 | }, 172 | ascending: { 173 | type: FieldsEnum 174 | }, 175 | descending: { 176 | type: FieldsEnum 177 | }, 178 | limit: { 179 | type: GraphQLInt 180 | }, 181 | equalTo: { 182 | type: createFieldsInputType('equalTo') 183 | }, 184 | greaterThan: { 185 | type: createFieldsInputType('greaterThan') 186 | }, 187 | greaterThanOrEqualTo: { 188 | type: createFieldsInputType('greaterThanOrEqualTo') 189 | }, 190 | lessThan: { 191 | type: createFieldsInputType('lessThan') 192 | }, 193 | lessThanOrEqualTo: { 194 | type: createFieldsInputType('lessThanOrEqualTo') 195 | }, 196 | containedIn: { 197 | type: createFieldsInputType('containedIn', new GraphQLList(GraphQLID)) 198 | }, 199 | containsAll: { 200 | type: createFieldsInputType('containsAll', new GraphQLList(GraphQLID)) 201 | }, 202 | exists: { 203 | type: createFieldsInputType('exists', GraphQLBoolean) 204 | } 205 | }, 206 | resolve: (source, args, {authOptions}, info) => { 207 | return addArgumentsToQuery(new AV.Query(className), args).find(authOptions); 208 | } 209 | }; 210 | }); 211 | 212 | return new GraphQLSchema({ 213 | query: new GraphQLObjectType({ 214 | name: 'LeanStorage', 215 | fields: querySchemas 216 | }), 217 | 218 | mutation: new GraphQLObjectType({ 219 | name: 'LeanStorageMutation', 220 | fields: _.mapValues(cloudSchemas, (schema, className) => { 221 | return { 222 | name: className, 223 | type: classSchemas[className], 224 | args: _.omitBy(classSchemasFieldsThunk[className](), value => { 225 | return value.type instanceof GraphQLList || value.type instanceof GraphQLObjectType; 226 | }), 227 | resolve: (source, args, {authOptions}, info) => { 228 | const saveOptions = _.extend({fetchWhenSave: true}, authOptions) 229 | 230 | if (args.objectId) { 231 | return AV.Object.createWithoutData(className, args.objectId).save(_.omit(args, 'objectId'), saveOptions); 232 | } else { 233 | return new classes[className]().save(args, saveOptions); 234 | } 235 | } 236 | } 237 | }) 238 | }) 239 | }); 240 | }); 241 | }; 242 | 243 | function addArgumentsToQuery(query, args) { 244 | ['ascending', 'descending', 'limit'].forEach( method => { 245 | if (args[method] !== undefined) { 246 | query[method](args[method]); 247 | } 248 | }); 249 | 250 | ['equalTo', 'greaterThan', 'greaterThanOrEqualTo', 'lessThan', 251 | 'lessThanOrEqualTo', 'containedIn', 'containsAll'].forEach( method => { 252 | if (_.isObject(args[method])) { 253 | _.forEach(args[method], (value, key) => { 254 | query[method](key, value); 255 | }); 256 | } 257 | }); 258 | 259 | if (_.isObject(args.exists)) { 260 | _.forEach(args.exists, (value, key) => { 261 | if (value) { 262 | query.exists(key); 263 | } else { 264 | query.doesNotExist(key); 265 | } 266 | }); 267 | } 268 | 269 | if (args.objectId) { 270 | query.equalTo('objectId', args.objectId); 271 | } 272 | 273 | return query; 274 | } 275 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const AV = require('leanengine'); 2 | 3 | AV.init({ 4 | appId: process.env.LEANCLOUD_APP_ID, 5 | appKey: process.env.LEANCLOUD_APP_KEY, 6 | masterKey: process.env.LEANCLOUD_APP_MASTER_KEY 7 | }); 8 | 9 | const app = require('./app'); 10 | const port = process.env.LEANCLOUD_APP_PORT || 3000; 11 | 12 | app.listen(port, () => { 13 | console.log('LeanCloud GraphQL is started on', port); 14 | }); 15 | 16 | module.exports = app; 17 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest-as-promised'); 2 | const chai = require('chai'); 3 | 4 | const server = require('../server'); 5 | 6 | chai.Should(); 7 | 8 | module.exports = function requestGraphQL(query, {sessionToken} = {}) { 9 | return supertest(server).post('/') 10 | .set('Content-Type', 'application/graphql') 11 | .set('X-LC-Session', sessionToken ? 'spoe8e2i1x6tyyip1eywe1siz' : '') 12 | .send(query); 13 | } 14 | -------------------------------------------------------------------------------- /test/mutation.js: -------------------------------------------------------------------------------- 1 | const requestGraphQL = require('./client'); 2 | 3 | describe('mutation', function() { 4 | var objectId; 5 | 6 | it('should create new object', () => { 7 | return requestGraphQL(` 8 | mutation { 9 | Leaderboard(name: "jysperm", score: 10) { 10 | objectId, name, score 11 | } 12 | } 13 | `, {sessionToken: true}).then( res => { 14 | const leaderboard = res.body.data.Leaderboard; 15 | objectId = leaderboard.objectId; 16 | 17 | leaderboard.should.be.include({ 18 | name: 'jysperm', 19 | score: 10 20 | }); 21 | }); 22 | }); 23 | 24 | it('should modify exists object', () => { 25 | return requestGraphQL(` 26 | mutation { 27 | Leaderboard(objectId: "${objectId}", score: 20) { 28 | score 29 | } 30 | } 31 | `, {sessionToken: true}).then( res => { 32 | res.body.data.Leaderboard.score.should.be.equal(20); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/query.js: -------------------------------------------------------------------------------- 1 | const {expect} = require('chai'); 2 | 3 | const requestGraphQL = require('./client'); 4 | 5 | describe('query', function() { 6 | it('should get all objects', () => { 7 | return requestGraphQL(` 8 | query { 9 | Todo { 10 | objectId, title, priority, createdAt 11 | } 12 | } 13 | `).then( res => { 14 | res.body.data.Todo.forEach( ({objectId, title, priority, createdAt}) => { 15 | objectId.should.be.a('string'); 16 | title.should.be.a('string'); 17 | priority.should.be.a('number'); 18 | createdAt.should.be.a('string'); 19 | }); 20 | }); 21 | }); 22 | 23 | it('should sort by priority', () => { 24 | return requestGraphQL(` 25 | query { 26 | Todo(ascending: priority) { 27 | title, priority 28 | } 29 | } 30 | `).then( res => { 31 | res.body.data.Todo.reduce( (previous, {priority}) => { 32 | priority.should.least(previous); 33 | return priority; 34 | }, -Infinity); 35 | }); 36 | }); 37 | 38 | it('should get only 2 objects', () => { 39 | return requestGraphQL(` 40 | query { 41 | Todo(limit: 2) { 42 | title, priority 43 | } 44 | } 45 | `).then( res => { 46 | res.body.data.Todo.length.should.be.equal(2); 47 | }); 48 | }); 49 | 50 | it('should get object by id', () => { 51 | return requestGraphQL(` 52 | query { 53 | Todo(objectId: "5853a0e5128fe1006b5ce449") { 54 | title 55 | } 56 | } 57 | `).then( res => { 58 | res.body.data.Todo.length.should.be.equal(1); 59 | res.body.data.Todo[0].title.should.be.equal('还信用卡账单'); 60 | }); 61 | }); 62 | 63 | it('should work with equalTo', () => { 64 | return requestGraphQL(` 65 | query { 66 | Todo(equalTo: {title: "团队会议"}) { 67 | title 68 | } 69 | } 70 | `).then( res => { 71 | res.body.data.Todo.length.should.be.equal(1); 72 | res.body.data.Todo[0].title.should.be.equal('团队会议'); 73 | }); 74 | }); 75 | 76 | it('should work with greaterThanOrEqualTo', () => { 77 | return requestGraphQL(` 78 | query { 79 | Todo(greaterThanOrEqualTo: {priority: 10}) { 80 | title, priority 81 | } 82 | } 83 | `).then( res => { 84 | res.body.data.Todo.forEach( ({priority}) => { 85 | priority.should.least(10); 86 | }); 87 | }); 88 | }); 89 | 90 | it('should work with containedIn', () => { 91 | return requestGraphQL(` 92 | query { 93 | Todo(containedIn: {tags: ["Online"]}) { 94 | title, tags 95 | } 96 | } 97 | `).then( res => { 98 | res.body.data.Todo.forEach( ({tags}) => { 99 | tags.should.eql(['Online']); 100 | }); 101 | }); 102 | }); 103 | 104 | it('should work with exists', () => { 105 | return requestGraphQL(` 106 | query { 107 | Todo(exists: {title: true, content: false}) { 108 | title, content 109 | } 110 | } 111 | `).then( res => { 112 | res.body.data.Todo.forEach( ({title, content}) => { 113 | title.should.be.a('string'); 114 | expect(content).to.not.exist; 115 | }); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/relation.js: -------------------------------------------------------------------------------- 1 | const requestGraphQL = require('./client'); 2 | 3 | describe('relation', function() { 4 | it('should populate relation', () => { 5 | return requestGraphQL(` 6 | query { 7 | TodoFolder { 8 | name, containedTodos { 9 | title, priority 10 | } 11 | } 12 | } 13 | `).then( res => { 14 | res.body.data.TodoFolder.forEach(todoFolder => { 15 | todoFolder.name.should.be.a('string'); 16 | todoFolder.containedTodos.forEach( todo => { 17 | todo.title.should.be.a('string'); 18 | todo.priority.should.be.a('number'); 19 | }); 20 | }); 21 | }); 22 | }); 23 | 24 | it('limit should work on relation', () => { 25 | return requestGraphQL(` 26 | query { 27 | TodoFolder { 28 | name, containedTodos(limit: 1) { 29 | title, priority 30 | } 31 | } 32 | } 33 | `).then( res => { 34 | res.body.data.TodoFolder.forEach(todoFolder => { 35 | todoFolder.containedTodos.length.should.be.equal(1); 36 | }); 37 | }); 38 | }); 39 | 40 | it('greaterThan should work on relation', () => { 41 | return requestGraphQL(` 42 | query { 43 | TodoFolder { 44 | name, containedTodos(greaterThan: {priority: 5}) { 45 | title, priority 46 | } 47 | } 48 | } 49 | `).then( res => { 50 | res.body.data.TodoFolder.forEach(todoFolder => { 51 | todoFolder.containedTodos.forEach( todo => { 52 | todo.priority.should.be.least(5); 53 | }); 54 | }); 55 | }); 56 | }); 57 | 58 | it('should populate pointer', () => { 59 | return requestGraphQL(` 60 | query { 61 | Todo(limit: 1) { 62 | title, owner { 63 | username, email 64 | } 65 | } 66 | } 67 | `).then( res => { 68 | res.body.data.Todo[0].title.should.be.a('string'); 69 | res.body.data.Todo[0].owner.username.should.be.a('string'); 70 | res.body.data.Todo[0].owner.email.should.be.a('string'); 71 | }); 72 | }); 73 | 74 | it('should work with multi-level relation', () => { 75 | return requestGraphQL(` 76 | query { 77 | TodoFolder { 78 | name, 79 | containedTodos { 80 | title, owner { 81 | username, email 82 | } 83 | } 84 | } 85 | } 86 | `).then( res => { 87 | res.body.data.TodoFolder.forEach(todoFolder => { 88 | todoFolder.containedTodos.forEach( todo => { 89 | if (todo.owner) { 90 | todo.owner.username.should.be.a('string'); 91 | todo.owner.email.should.be.a('string'); 92 | } 93 | }); 94 | }); 95 | }); 96 | }); 97 | 98 | it('should populate reverse pointer', () => { 99 | return requestGraphQL(` 100 | query { 101 | _User { 102 | username, ownerOfTodo { 103 | title 104 | } 105 | } 106 | } 107 | `).then( res => { 108 | res.body.data._User.forEach( user => { 109 | user.ownerOfTodo.forEach( todo => { 110 | todo.title.should.be.a('string'); 111 | }); 112 | }); 113 | }); 114 | }); 115 | 116 | it('exists should work in reverse pointer', () => { 117 | return requestGraphQL(` 118 | query { 119 | _User { 120 | username, ownerOfTodo(exists: {content: true}) { 121 | title, content 122 | } 123 | } 124 | } 125 | `).then( res => { 126 | res.body.data._User.forEach( user => { 127 | user.ownerOfTodo.forEach( todo => { 128 | todo.content.should.be.a('string'); 129 | }); 130 | }); 131 | }); 132 | }); 133 | 134 | it('should populate reverse relation', () => { 135 | return requestGraphQL(` 136 | query { 137 | Todo { 138 | containedTodosOfTodoFolder { 139 | name 140 | } 141 | } 142 | } 143 | `).then( res => { 144 | res.body.data.Todo.forEach( todo => { 145 | todo.containedTodosOfTodoFolder.forEach( todoFolder => { 146 | todoFolder.name.should.be.a('string'); 147 | }); 148 | }); 149 | }); 150 | }); 151 | 152 | it('limit should work in reverse relation', () => { 153 | return requestGraphQL(` 154 | query { 155 | Todo { 156 | containedTodosOfTodoFolder(limit: 1) { 157 | name 158 | } 159 | } 160 | } 161 | `).then( res => { 162 | res.body.data.Todo.forEach( todo => { 163 | todo.containedTodosOfTodoFolder.length.should.be.most(1); 164 | }); 165 | }); 166 | }); 167 | }); 168 | --------------------------------------------------------------------------------