From d638e240bf6293e3926416361af4b48efcf3e873 Mon Sep 17 00:00:00 2001 From: dschafer Date: Tue, 11 Aug 2015 13:18:33 -0400 Subject: [PATCH] Initial commit. --- .eslintrc | 259 ++++++++ .flowconfig | 10 + .gitignore | 10 + .travis.yml | 9 + CONTRIBUTING.md | 35 ++ LICENSE | 30 + PATENTS | 11 + README.md | 238 ++++++++ package.json | 60 ++ scripts/mocha-bootload.js | 17 + scripts/watch.js | 201 ++++++ src/__tests__/starWarsConnectionTests.js | 246 ++++++++ src/__tests__/starWarsData.js | 90 +++ src/__tests__/starWarsMutationTests.js | 56 ++ .../starWarsObjectIdentificationTests.js | 100 +++ src/__tests__/starWarsSchema.js | 308 ++++++++++ src/connection/__tests__/arrayconnection.js | 575 ++++++++++++++++++ .../__tests__/asyncarrayconnection.js | 74 +++ src/connection/__tests__/connection.js | 124 ++++ src/connection/arrayconnection.js | 146 +++++ src/connection/connection.js | 124 ++++ src/connection/connectiontypes.js | 50 ++ src/index.js | 38 ++ src/mutation/__tests__/mutation.js | 291 +++++++++ src/mutation/mutation.js | 90 +++ src/node/__tests__/global.js | 165 +++++ src/node/__tests__/node.js | 339 +++++++++++ src/node/__tests__/nodeasync.js | 109 ++++ src/node/__tests__/plural.js | 157 +++++ src/node/node.js | 114 ++++ src/node/plural.js | 54 ++ src/utils/base64.js | 19 + 32 files changed, 4149 insertions(+) create mode 100644 .eslintrc create mode 100644 .flowconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 PATENTS create mode 100644 README.md create mode 100644 package.json create mode 100644 scripts/mocha-bootload.js create mode 100644 scripts/watch.js create mode 100644 src/__tests__/starWarsConnectionTests.js create mode 100644 src/__tests__/starWarsData.js create mode 100644 src/__tests__/starWarsMutationTests.js create mode 100644 src/__tests__/starWarsObjectIdentificationTests.js create mode 100644 src/__tests__/starWarsSchema.js create mode 100644 src/connection/__tests__/arrayconnection.js create mode 100644 src/connection/__tests__/asyncarrayconnection.js create mode 100644 src/connection/__tests__/connection.js create mode 100644 src/connection/arrayconnection.js create mode 100644 src/connection/connection.js create mode 100644 src/connection/connectiontypes.js create mode 100644 src/index.js create mode 100644 src/mutation/__tests__/mutation.js create mode 100644 src/mutation/mutation.js create mode 100644 src/node/__tests__/global.js create mode 100644 src/node/__tests__/node.js create mode 100644 src/node/__tests__/nodeasync.js create mode 100644 src/node/__tests__/plural.js create mode 100644 src/node/node.js create mode 100644 src/node/plural.js create mode 100644 src/utils/base64.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..36c9b5c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,259 @@ +{ + "parser": "babel-eslint", + "arrowFunctions": true, + "blockBindings": true, + "classes": true, + "defaultParams": true, + "destructuring": true, + "forOf": true, + "generators": true, + "modules": true, + "objectLiteralComputedProperties": true, + "objectLiteralShorthandMethods": true, + "objectLiteralShorthandProperties": true, + "spread": true, + "templateStrings": true, + "env": { + "node": true, + "es6": true + }, + "rules": { + "comma-dangle": 0, + "no-cond-assign": 2, + "no-console": 0, + "no-constant-condition": 2, + "no-control-regex": 0, + "no-debugger": 0, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-empty": 2, + "no-empty-class": 2, + "no-ex-assign": 2, + "no-extra-boolean-cast": 2, + "no-extra-parens": 0, + "no-extra-semi": 2, + "no-func-assign": 2, + "no-inner-declarations": [ + 2, + "functions" + ], + "no-invalid-regexp": 2, + "no-irregular-whitespace": 2, + "no-negated-in-lhs": 2, + "no-obj-calls": 2, + "no-regex-spaces": 2, + "no-reserved-keys": 0, + "no-sparse-arrays": 2, + "no-unreachable": 2, + "use-isnan": 2, + "valid-jsdoc": 0, + "valid-typeof": 2, + "block-scoped-var": 0, + "complexity": 0, + "consistent-return": 0, + "curly": [ + 2, + "all" + ], + "default-case": 0, + "dot-notation": 0, + "eqeqeq": 2, + "guard-for-in": 2, + "no-alert": 2, + "no-caller": 2, + "no-div-regex": 2, + "no-empty-label": 2, + "no-eq-null": 0, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-fallthrough": 2, + "no-floating-decimal": 2, + "no-implied-eval": 2, + "no-iterator": 2, + "no-labels": 0, + "no-lone-blocks": 0, + "no-loop-func": 0, + "no-multi-spaces": 2, + "no-multi-str": 2, + "no-native-reassign": 0, + "no-new": 2, + "no-new-func": 0, + "no-new-wrappers": 2, + "no-octal": 2, + "no-octal-escape": 2, + "no-param-reassign": 2, + "no-process-env": 0, + "no-proto": 2, + "no-redeclare": 2, + "no-return-assign": 2, + "no-script-url": 2, + "no-self-compare": 0, + "no-sequences": 2, + "no-throw-literal": 2, + "no-unused-expressions": 2, + "no-void": 2, + "no-warning-comments": 0, + "no-with": 2, + "radix": 2, + "vars-on-top": 0, + "wrap-iife": 2, + "yoda": [ + 2, + "never", + { + "exceptRange": true + } + ], + "strict": 0, + "no-catch-shadow": 2, + "no-delete-var": 2, + "no-label-var": 2, + "no-shadow": 2, + "no-shadow-restricted-names": 2, + "no-undef": 2, + "no-undef-init": 2, + "no-undefined": 0, + "no-unused-vars": [ + 2, + { + "vars": "all", + "args": "after-used" + } + ], + "no-use-before-define": 0, + "handle-callback-err": [ + 2, + "error" + ], + "no-mixed-requires": [ + 2, + true + ], + "no-new-require": 2, + "no-path-concat": 2, + "no-process-exit": 0, + "no-restricted-modules": 0, + "no-sync": 2, + "indent": [ + 2, + 2, + { + "indentSwitchCase": true + } + ], + "brace-style": [ + 2, + "1tbs", + { + "allowSingleLine": true + } + ], + "camelcase": [ + 2, + { + "properties": "always" + } + ], + "comma-spacing": 0, + "comma-style": [ + 2, + "last" + ], + "consistent-this": 0, + "eol-last": 2, + "func-names": 0, + "func-style": 0, + "key-spacing": [ + 2, + { + "beforeColon": false, + "afterColon": true + } + ], + "max-nested-callbacks": 0, + "new-cap": 0, + "new-parens": 2, + "newline-after-var": 0, + "no-array-constructor": 2, + "no-inline-comments": 0, + "no-lonely-if": 2, + "no-mixed-spaces-and-tabs": 2, + "no-multiple-empty-lines": 0, + "no-nested-ternary": 0, + "no-new-object": 2, + "no-spaced-func": 2, + "no-ternary": 0, + "no-trailing-spaces": 2, + "no-underscore-dangle": 0, + "no-wrap-func": 2, + "one-var": [ + 2, + "never" + ], + "operator-assignment": [ + 2, + "always" + ], + "padded-blocks": 0, + "quote-props": [ + 2, + "as-needed" + ], + "quotes": [ + 2, + "single" + ], + "semi": [ + 2, + "always" + ], + "semi-spacing": [ + 2, + { + "before": false, + "after": true + } + ], + "sort-vars": 0, + "space-after-keywords": [ + 2, + "always" + ], + "space-before-blocks": [ + 2, + "always" + ], + "space-before-function-paren": [ + 2, + { + "anonymous": "always", + "named": "never" + } + ], + "space-in-brackets": 0, + "space-in-parens": 0, + "space-infix-ops": [ + 2, + { + "int32Hint": false + } + ], + "space-return-throw-case": 2, + "space-unary-ops": [ + 2, + { + "words": true, + "nonwords": false + } + ], + "spaced-line-comment": [ + 2, + "always" + ], + "wrap-regex": 0, + "no-var": 0, + "max-len": [2, 80, 4] + } +} diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000..cbc02c5 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,10 @@ +[ignore] +.*/coverage/.* +.*/scripts/.* +.*/node_modules/flow-bin/.* + +[include] + +[libs] + +[options] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74ff842 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.swp +*~ +.*.haste_cache.* +.DS_Store +.idea + +lib +node_modules +npm-debug.log +coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3dff11f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: node_js + +node_js: + - "iojs" + - "0.12" + - "0.10" + +notifications: + email: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..30d3f4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +Contributing +============ + +After cloning this repo, ensure dependencies are installed by running: + +```sh +npm install +``` + +GraphQL Relay is written in ES6 using [Babel](http://babeljs.io/), widely +consumable JavaScript can be produced by running: + +```sh +npm run build +``` + +Once `npm run build` has run, you may `import` or `require()` directly from +node. + +The full test suite can be evaluated by running: + +```sh +npm test +``` + +While actively developing, we recommend running + +```sh +npm run watch +``` + +in a terminal. This will watch the file system run lint, tests, and type +checking automatically whenever you save a js file. + +To lint the JS files and type interface checks run `npm run lint`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47fb5de --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For GraphQL software + +Copyright (c) 2015, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/PATENTS b/PATENTS new file mode 100644 index 0000000..953668b --- /dev/null +++ b/PATENTS @@ -0,0 +1,11 @@ +Additional Grant of Patent Rights Version 2 + +"Software" means the GraphQL software distributed by Facebook, Inc. + +Facebook, Inc. (“Facebook”) hereby grants to each recipient of the Software (“you”) a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (subject to the termination provision below) license under any Necessary Claims, to make, have made, use, sell, offer to sell, import, and otherwise transfer the Software. For avoidance of doubt, no license is granted under Facebook’s rights in any patent claims that are infringed by (i) modifications to the Software made by you or any third party or (ii) the Software in combination with any software or other technology. + +The license granted hereunder will terminate, automatically and without notice, if you (or any of your subsidiaries, corporate affiliates or agents) initiate directly or indirectly, or take a direct financial interest in, any Patent Assertion: (i) against Facebook or any of its subsidiaries or corporate affiliates, (ii) against any party if such Patent Assertion arises in whole or in part from any software, technology, product or service of Facebook or any of its subsidiaries or corporate affiliates, or (iii) against any party relating to the Software. Notwithstanding the foregoing, if Facebook or any of its subsidiaries or corporate affiliates files a lawsuit alleging patent infringement against you in the first instance, and you respond by filing a patent infringement counterclaim in that lawsuit against that party that is unrelated to the Software, the license granted hereunder will not terminate under section (i) of this paragraph due to such counterclaim. + +A “Necessary Claim” is a claim of a patent owned by Facebook that is necessarily infringed by the Software standing alone. + +A “Patent Assertion” is any lawsuit or other action alleging direct, indirect, or contributory infringement or inducement to infringe any patent, including a cross-claim or counterclaim. diff --git a/README.md b/README.md new file mode 100644 index 0000000..268b9db --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# Relay Library for GraphQL.js + +This is a library to allow the easy creation of Relay-compliant servers using +the [GraphQL.js](https://github.com/graphql/graphql-js) reference implementation +of a GraphQL server. + +[![Build Status](https://magnum.travis-ci.com/graphql/graphql-relay-js.svg?token=KWQyerQ76yNVcpKTihbJ&branch=master)](https://magnum.travis-ci.com/graphql/graphql-relay-js) + +## Getting Started + +A basic understanding of GraphQL and of the GraphQL.js implementation is needed +to provide context for this library. + +An overview of GraphQL in general is available in the +[README](https://github.com/facebook/graphql/blob/master/README.md) for the +[Specification for GraphQL](https://github.com/facebook/graphql). + +This library is designed to work with the +the [GraphQL.js](https://github.com/graphql/graphql-js) reference implementation +of a GraphQL server. + +An overview of the functionality that a Relay-compliant GraphQL server should +provide is in the [GraphQL Relay Specification](https://facebook.github.io/relay/docs/graphql-relay-specification.html) +on the [Relay website](https://facebook.github.io/relay/). That overview +describes a simple set of examples that exist as [tests](src/__tests__) in this +repository. A good way to get started with this repository is to walk through +that documentation and the corresponding tests in this library together. + +## Using Relay Library for GraphQL.js + +Install Relay Library for GraphQL.js + +```sh +npm install graphql-relay +``` + +When building a schema for [GraphQL.js](https://github.com/graphql/graphql-js), +the provided library functions can be used to simplify the creation of Relay +patterns. + +### Connections + +Helper functions are provided for both building the GraphQL types +for connections and for implementing the `resolve` method for fields +returning those types. + + - `connectionArgs` returns the arguments that fields should provide when +they return a connection type. + - `connectionDefinitions` returns a `connectionType` and its associated +`edgeType`, given a name and a node type. + - `connectionFromArray` is a helper method that takes an array and the +arguments from `connectionArgs`, does pagination and filtering, and returns +an object in the shape expected by a `connectionType`'s `resolve` function. + - `connectionFromPromisedArray` is similar to `connectionFromArray`, but +it takes a promise that resolves to an array, and returns a promise that +resolves to the expected shape by `connectionType`. + - `cursorForObjectInConnection` is a helper method that takes an array and a +member object, and returns a cursor for use in the mutation payload. + +An example usage of these methods from the [test schema](src/__tests__/starWarsSchema.js): + +```js +var {connectionType: ShipConnection} = + connectionDefinitions({name: 'Ship', nodeType: shipType}); +var factionType = new GraphQLObjectType({ + name: 'Faction', + fields: () => ({ + ships: { + type: ShipConnection, + args: connectionArgs, + resolve: (faction, args) => connectionFromArray( + faction.ships.map((id) => data.Ship[id]), + args + ), + } + }), +}); +``` + +This shows adding a `ships` field to the `Faction` object that is a connection. +It uses `connectionDefinitions({name: 'Ship', nodeType: shipType})` to create +the connection type, adds `connectionArgs` as arguments on this function, and +then implements the resolve function by passing the array of ships and the +arguments to `connectionFromArray`. + +### Object Identification + +Helper functions are provided for both building the GraphQL types +for nodes and for implementing global IDs around local IDs. + + - `nodeDefinitions` returns the `Node` interface that objects can implement, +and returns the `node` root field to include on the query type. To implement +this, it takes a function to resolve an ID to an object, and to determine +the type of a given object. + - `toGlobalId` takes a type name and an ID specific to that type name, +and returns a "global ID" that is unique among all types. + - `fromGlobalId` takes the "global ID" created by `toGlobalID`, and retuns +the type name and ID used to create it. + - `globalIdField` creates the configuration for an `id` field on a node. + - `pluralIdentifyingRootField` creates a field that accepts a list of +non-ID identifiers (like a username) and maps then to their corresponding +objects. + +An example usage of these methods from the [test schema](src/__tests__/starWarsSchema.js): + +```js +var {nodeInterface, nodeField} = nodeDefinitions( + (globalId) => { + var {type, id} = fromGlobalId(globalId); + return data[type][id]; + }, + (obj) => { + return obj.ships ? factionType : shipType; + } +); + +var factionType = new GraphQLObjectType({ + name: 'Faction', + fields: () => ({ + id: globalIdField('Faction'), + }), + interfaces: [nodeInterface] +}); + +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + node: nodeField + }) +}); +``` + +This uses `nodeDefinitions` to construct the `Node` interface and the `node` +field; it uses `fromGlobalId` to resolve the IDs passed in in the implementation +of the function mapping ID to object. It then uses the `globalIdField` method to +create the `id` field on `Faction`, which also ensures implements the +`nodeInterface`. Finally, it adds the `node` field to the query type, using the +`nodeField` returned by `nodeDefinitions`. + +### Mutations + +A helper function is provided for building mutations with +single inputs and client mutation IDs. + + - `mutationWithClientMutationId` takes a name, input fields, output fields, +and a mutation method to map from the input fields to the output fields, +performing the mutation along the way. It then creates and returns a field +configuration that can be used as a top-level field on the mutation type. + +An example usage of these methods from the [test schema](src/__tests__/starWarsSchema.js): + +```js +var shipMutation = mutationWithClientMutationId({ + name: 'IntroduceShip', + inputFields: { + shipName: { + type: new GraphQLNonNull(GraphQLString) + }, + factionId: { + type: new GraphQLNonNull(GraphQLID) + } + }, + outputFields: { + ship: { + type: shipType, + resolve: (payload) => data['Ship'][payload.shipId] + }, + faction: { + type: factionType, + resolve: (payload) => data['Faction'][payload.factionId] + } + }, + mutateAndGetPayload: ({shipName, factionId}) => { + var newShip = { + id: getNewShipId(), + name: shipName + }; + data.Ship[newShip.id] = newShip; + data.Faction[factionId].ships.push(newShip.id); + return { + shipId: newShip.id, + factionId: factionId, + }; + } +}); + +var mutationType = new GraphQLObjectType({ + name: 'Mutation', + fields: () => ({ + introduceShip: shipMutation + }) +}); +``` + +This code creates a mutation named `IntroduceShip`, which takes a faction +ID and a ship name as input. It outputs the `Faction` and the `Ship` in +question. `mutateAndGetPayload` then gets an object with a property for +each input field, performs the mutation by constructing the new ship, then +returns an object that will be resolved by the output fields. + +Our mutation type then creates the `introduceShip` field using the return +value of `mutationWithClientMutationId`. + +## Contributing + +After cloning this repo, ensure dependencies are installed by running: + +```sh +npm install +``` + +This library is written in ES6 and uses [Babel](http://babeljs.io/) for ES5 +transpilation and [Flow](http://flowtype.org/) for type safety. Widely +consumable JavaScript can be produced by running: + +```sh +npm run build +``` + +Once `npm run build` has run, you may `import` or `require()` directly from +node. + +After developing, the full test suite can be evaluated by running: + +```sh +npm test +``` + +While actively developing, we recommend running + +```sh +npm run watch +``` + +in a terminal. This will watch the file system run lint, tests, and type +checking automatically whenever you save a js file. + +To lint the JS files and type interface checks run `npm run lint`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..30c225c --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "graphql-relay", + "version": "0.1.0", + "description": "A library to help construct a graphql-js server supporting react-relay.", + "contributors": [ + "Daniel Schafer " + ], + "license": "BSD-3-Clause", + "homepage": "https://github.com/graphql/graphql-relay-js", + "bugs": { + "url": "https://github.com/graphql/graphql-relay-js/issues" + }, + "repository": { + "type": "git", + "url": "http://github.com/graphql/graphql-relay-js.git" + }, + "main": "lib/index.js", + "directories": { + "lib": "./lib" + }, + "files": [ + "lib", + "README.md", + "LICENSE", + "PATENTS" + ], + "options": { + "mocha": "--require scripts/mocha-bootload src/**/__tests__/**/*.js" + }, + "babel": { + "optional": ["runtime", "es7.asyncFunctions", "es7.objectRestSpread"] + }, + "scripts": { + "prepublish": "npm test && npm run build", + "test": "npm run lint && npm run check && mocha $npm_package_options_mocha", + "testonly": "mocha $npm_package_options_mocha", + "lint": "eslint src", + "check": "flow check", + "build": "rm -rf lib/* && babel src --ignore __tests__ --optional runtime --out-dir lib", + "watch": "babel --optional runtime scripts/watch.js | node" + }, + "dependencies": { + "babel-runtime": "~5.8.3" + }, + "peerDependencies": { + "graphql": "~0.2.6" + }, + "devDependencies": { + "babel": "5.8.3", + "babel-core": "5.8.3", + "babel-eslint": "4.0.5", + "chai": "3.0.0", + "chai-as-promised": "5.1.0", + "eslint": "0.24.0", + "flow-bin": "0.13.1", + "graphql": "0.2.6", + "mocha": "2.2.5", + "sane": "1.1.3" + } +} diff --git a/scripts/mocha-bootload.js b/scripts/mocha-bootload.js new file mode 100644 index 0000000..7fd28ea --- /dev/null +++ b/scripts/mocha-bootload.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +require('babel/register')({ + optional: ['runtime'] +}); + +var chai = require('chai'); + +var chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); diff --git a/scripts/watch.js b/scripts/watch.js new file mode 100644 index 0000000..359bf98 --- /dev/null +++ b/scripts/watch.js @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import sane from 'sane'; +import { resolve as resolvePath } from 'path'; +import { spawn } from 'child_process'; +import flowBinPath from 'flow-bin'; + + +process.env.PATH += ':./node_modules/.bin'; + +var cmd = resolvePath(__dirname); +var srcDir = resolvePath(cmd, './src'); + +function exec(command, options) { + return new Promise(function (resolve, reject) { + var child = spawn(command, options, { + cmd: cmd, + env: process.env, + stdio: 'inherit' + }); + child.on('exit', function (code) { + if (code === 0) { + resolve(true); + } else { + reject(new Error('Error code: ' + code)); + } + }); + }); +} + +var flowServer = spawn(flowBinPath, ['server'], { + cmd: cmd, + env: process.env +}); + +var watcher = sane(srcDir, { glob: ['**/*.*'] }) + .on('ready', startWatch) + .on('add', changeFile) + .on('delete', deleteFile) + .on('change', changeFile); + +process.on('SIGINT', function () { + watcher.close(); + flowServer.kill(); + console.log(CLEARLINE + yellow(invert('stopped watching'))); + process.exit(); +}); + +var isChecking; +var needsCheck; +var toCheck = {}; +var timeout; + +function startWatch() { + process.stdout.write(CLEARSCREEN + green(invert('watching...'))); +} + +function changeFile(filepath, root, stat) { + if (!stat.isDirectory()) { + toCheck[filepath] = true; + debouncedCheck(); + } +} + +function deleteFile(filepath) { + delete toCheck[filepath]; + debouncedCheck(); +} + +function debouncedCheck() { + needsCheck = true; + clearTimeout(timeout); + timeout = setTimeout(guardedCheck, 250); +} + +function guardedCheck() { + if (isChecking || !needsCheck) { + return; + } + isChecking = true; + var filepaths = Object.keys(toCheck); + toCheck = {}; + needsCheck = false; + checkFiles(filepaths).then(() => { + isChecking = false; + process.nextTick(guardedCheck); + }); +} + +function checkFiles(filepaths) { + console.log('\u001b[2J'); + + return parseFiles(filepaths) + .then(() => runTests(filepaths)) + .then(testSuccess => lintFiles(filepaths) + .then(lintSuccess => typecheckStatus() + .then(typecheckSuccess => + testSuccess && lintSuccess && typecheckSuccess))) + .catch(() => false) + .then(success => { + process.stdout.write( + '\n' + (success ? '' : '\x07') + green(invert('watching...')) + ); + }); +} + +// Checking steps + +function parseFiles(filepaths) { + console.log('Checking Syntax'); + + return Promise.all(filepaths.map(filepath => { + if (isJS(filepath) && !isTest(filepath)) { + return exec('babel', [ + '--optional', 'runtime', + '--out-file', '/dev/null', + srcPath(filepath) + ]); + } + })); +} + +function runTests(filepaths) { + console.log('\nRunning Tests'); + + return exec('mocha', [ + '--reporter', 'progress', + '--require', 'scripts/mocha-bootload' + ].concat( + allTests(filepaths) ? filepaths.map(srcPath) : ['src/**/__tests__/**/*.js'] + )).catch(() => false); +} + +function lintFiles(filepaths) { + console.log('Linting Code\n'); + + return filepaths.reduce((prev, filepath) => prev.then(prevSuccess => { + process.stdout.write(' ' + filepath + ' ...'); + return exec('eslint', [srcPath(filepath)]) + .catch(() => false) + .then(success => { + console.log(CLEARLINE + ' ' + (success ? CHECK : X) + ' ' + filepath); + return prevSuccess && success; + }); + }), Promise.resolve(true)); +} + +function typecheckStatus() { + console.log('\nType Checking\n'); + return exec(flowBinPath, ['status']).catch(() => false); +} + +// Filepath + +function srcPath(filepath) { + return resolvePath(srcDir, filepath); +} + +// Predicates + +function isJS(filepath) { + return filepath.indexOf('.js') === filepath.length - 3; +} + +function allTests(filepaths) { + return filepaths.length > 0 && filepaths.every(isTest); +} + +function isTest(filepath) { + return isJS(filepath) && ~filepath.indexOf('__tests__/'); +} + +// Print helpers + +var CLEARSCREEN = '\u001b[2J'; +var CLEARLINE = '\r\x1B[K'; +var CHECK = green('\u2713'); +var X = red('\u2718'); + +function invert(str) { + return `\u001b[7m ${str} \u001b[27m`; +} + +function red(str) { + return `\x1B[K\u001b[1m\u001b[31m${str}\u001b[39m\u001b[22m`; +} + +function green(str) { + return `\x1B[K\u001b[1m\u001b[32m${str}\u001b[39m\u001b[22m`; +} + +function yellow(str) { + return `\x1B[K\u001b[1m\u001b[33m${str}\u001b[39m\u001b[22m`; +} diff --git a/src/__tests__/starWarsConnectionTests.js b/src/__tests__/starWarsConnectionTests.js new file mode 100644 index 0000000..b93a07e --- /dev/null +++ b/src/__tests__/starWarsConnectionTests.js @@ -0,0 +1,246 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { StarWarsSchema } from './starWarsSchema.js'; +import { graphql } from 'graphql'; + +// 80+ char lines are useful in describe/it, so ignore in this file. +/*eslint-disable max-len */ + +describe('Connection Tests', () => { + describe('Fetching Tests', () => { + it('Correctly fetches the first ship of the rebels', async () => { + var query = ` + query RebelsShipsQuery { + rebels { + name, + ships(first: 1) { + edges { + node { + name + } + } + } + } + } + `; + var expected = { + rebels: { + name: 'Alliance to Restore the Republic', + ships: { + edges: [ + { + node: { + name: 'X-Wing' + } + } + ] + } + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly fetches the first two ships of the rebels with a cursor', async () => { + var query = ` + query MoreRebelShipsQuery { + rebels { + name, + ships(first: 2) { + edges { + cursor, + node { + name + } + } + } + } + } + `; + var expected = { + rebels: { + name: 'Alliance to Restore the Republic', + ships: { + edges: [ + { + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + node: { + name: 'X-Wing' + } + }, + { + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + node: { + name: 'Y-Wing' + } + } + ] + } + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly fetches the next three ships of the rebels with a cursor', async () => { + var query = ` + query EndOfRebelShipsQuery { + rebels { + name, + ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjE=") { + edges { + cursor, + node { + name + } + } + } + } + } + `; + var expected = { + rebels: { + name: 'Alliance to Restore the Republic', + ships: { + edges: [ + { + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + node: { + name: 'A-Wing' + } + }, + { + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + node: { + name: 'Millenium Falcon' + } + }, + { + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + node: { + name: 'Home One' + } + } + ] + } + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly fetches no ships of the rebels at the end of connection', async () => { + var query = ` + query RebelsQuery { + rebels { + name, + ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjQ=") { + edges { + cursor, + node { + name + } + } + } + } + } + `; + var expected = { + rebels: { + name: 'Alliance to Restore the Republic', + ships: { + edges: [] + } + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly identifies the end of the list', async () => { + var query = ` + query EndOfRebelShipsQuery { + rebels { + name, + originalShips: ships(first: 2) { + edges { + node { + name + } + } + pageInfo { + hasNextPage + } + } + moreShips: ships(first: 3 after: "YXJyYXljb25uZWN0aW9uOjE=") { + edges { + node { + name + } + } + pageInfo { + hasNextPage + } + } + } + } + `; + var expected = { + rebels: { + name: 'Alliance to Restore the Republic', + originalShips: { + edges: [ + { + node: { + name: 'X-Wing' + } + }, + { + node: { + name: 'Y-Wing' + } + } + ], + pageInfo: { + hasNextPage: true + } + }, + moreShips: { + edges: [ + { + node: { + name: 'A-Wing' + } + }, + { + node: { + name: 'Millenium Falcon' + } + }, + { + node: { + name: 'Home One' + } + } + ], + pageInfo: { + hasNextPage: false + } + } + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + }); +}); diff --git a/src/__tests__/starWarsData.js b/src/__tests__/starWarsData.js new file mode 100644 index 0000000..e3eca33 --- /dev/null +++ b/src/__tests__/starWarsData.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * This defines a basic set of data for our Star Wars Schema. + * + * This data is hard coded for the sake of the demo, but you could imagine + * fetching this data from a backend service rather than from hardcoded + * JSON objects in a more complex demo. + */ + +var xwing = { + id: '1', + name: 'X-Wing', +}; + +var ywing = { + id: '2', + name: 'Y-Wing', +}; + +var awing = { + id: '3', + name: 'A-Wing', +}; + +// Yeah, technically it's Corellian. But it flew in the service of the rebels, +// so for the purposes of this demo it's a rebel ship. +var falcon = { + id: '4', + name: 'Millenium Falcon', +}; + +var homeOne = { + id: '5', + name: 'Home One', +}; + +var tieFighter = { + id: '6', + name: 'TIE Fighter', +}; + +var tieInterceptor = { + id: '7', + name: 'TIE Interceptor', +}; + +var executor = { + id: '8', + name: 'Executor', +}; + +var nextShip = 9; +export function getNewShipId() { + return '' + (nextShip++); +} + +export var rebels = { + id: '1', + name: 'Alliance to Restore the Republic', + ships: ['1', '2', '3', '4', '5'] +}; + +export var empire = { + id: '2', + name: 'Galactic Empire', + ships: ['6', '7', '8'] +}; + +export var data = { + Faction: { + 1: rebels, + 2: empire + }, + Ship: { + 1: xwing, + 2: ywing, + 3: awing, + 4: falcon, + 5: homeOne, + 6: tieFighter, + 7: tieInterceptor, + 8: executor + } +}; diff --git a/src/__tests__/starWarsMutationTests.js b/src/__tests__/starWarsMutationTests.js new file mode 100644 index 0000000..5facd94 --- /dev/null +++ b/src/__tests__/starWarsMutationTests.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { StarWarsSchema } from './starWarsSchema.js'; +import { graphql } from 'graphql'; + +// 80+ char lines are useful in describe/it, so ignore in this file. +/*eslint-disable max-len */ + +describe('Mutation Tests', () => { + it('Correctly mutates the data set', async () => { + var mutation = ` + mutation AddBWingQuery($input: IntroduceShipInput!) { + introduceShip(input: $input) { + ship { + id + name + } + faction { + name + } + clientMutationId + } + } + `; + var params = { + input: { + shipName: 'B-Wing', + factionId: '1', + clientMutationId: 'abcde', + } + }; + var expected = { + introduceShip: { + ship: { + id: 'U2hpcDo5', + name: 'B-Wing' + }, + faction: { + name: 'Alliance to Restore the Republic' + }, + clientMutationId: 'abcde', + } + }; + var result = await graphql(StarWarsSchema, mutation, null, params); + expect(result).to.deep.equal({ data: expected }); + }); +}); diff --git a/src/__tests__/starWarsObjectIdentificationTests.js b/src/__tests__/starWarsObjectIdentificationTests.js new file mode 100644 index 0000000..b8c5d36 --- /dev/null +++ b/src/__tests__/starWarsObjectIdentificationTests.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { StarWarsSchema } from './starWarsSchema.js'; +import { graphql } from 'graphql'; + +// 80+ char lines are useful in describe/it, so ignore in this file. +/*eslint-disable max-len */ + +describe('Object Identification Tests', () => { + describe('Fetching Tests', () => { + it('Correctly fetches the ID and name of the rebels', async () => { + var query = ` + query RebelsQuery { + rebels { + id + name + } + } + `; + var expected = { + rebels: { + id: 'RmFjdGlvbjox', + name: 'Alliance to Restore the Republic' + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly refetches the rebels', async () => { + var query = ` + query RebelsRefetchQuery { + node(id: "RmFjdGlvbjox") { + id + ... on Faction { + name + } + } + } + `; + var expected = { + node: { + id: 'RmFjdGlvbjox', + name: 'Alliance to Restore the Republic' + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly fetches the ID and name of the empire', async () => { + var query = ` + query EmpireQuery { + empire { + id + name + } + } + `; + var expected = { + empire: { + id: 'RmFjdGlvbjoy', + name: 'Galactic Empire' + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + + it('Correctly refetches the rebels', async () => { + var query = ` + query EmpireRefetchQuery { + node(id: "RmFjdGlvbjoy") { + id + ... on Faction { + name + } + } + } + `; + var expected = { + node: { + id: 'RmFjdGlvbjoy', + name: 'Galactic Empire' + } + }; + var result = await graphql(StarWarsSchema, query); + expect(result).to.deep.equal({ data: expected }); + }); + }); +}); diff --git a/src/__tests__/starWarsSchema.js b/src/__tests__/starWarsSchema.js new file mode 100644 index 0000000..227acd1 --- /dev/null +++ b/src/__tests__/starWarsSchema.js @@ -0,0 +1,308 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { + GraphQLID, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from 'graphql'; + +import { + nodeDefinitions, + globalIdField, + fromGlobalId +} from '../node/node.js'; + +import { + connectionFromArray +} from '../connection/arrayconnection.js'; + +import { + connectionArgs, + connectionDefinitions +} from '../connection/connection.js'; + +import { + mutationWithClientMutationId +} from '../mutation/mutation.js'; + +import { + rebels, + empire, + data, + getNewShipId, +} from './starWarsData.js'; + +/** + * This is a basic end-to-end test, designed to demonstrate the various + * capabilities of a Relay-compliant GraphQL server. + * + * It is recommended that readers of this test be familiar with + * the end-to-end test in GraphQL.js first, as this test skips + * over the basics covered there in favor of illustrating the + * key aspects of the Relay spec that this test is designed to illustrate. + * + * We will create a GraphQL schema that describes the major + * factions and ships in the original Star Wars trilogy. + * + * NOTE: This may contain spoilers for the original Star + * Wars trilogy. + */ + +/** + * Using our shorthand to describe type systems, the type system for our + * example will be the followng: + * + * interface Node { + * id: ID! + * } + * + * type Faction : Node { + * id: ID! + * name: String + * ships: ShipConnection + * } + * + * type Ship : Node { + * id: ID! + * name: String + * } + * + * type ShipConnection { + * edges: [ShipEdge] + * pageInfo: PageInfo! + * } + * + * type ShipEdge { + * cursor: String! + * node: Ship + * } + * + * type PageInfo { + * hasNextPage: Boolean! + * hasPreviousPage: Boolean! + * startCursor: String + * endCursor: String + * } + * + * type Query { + * rebels: Faction + * empire: Faction + * node(id: ID!): Node + * } + * + * input IntroduceShipInput { + * clientMutationId: string! + * shipName: string! + * factionId: ID! + * } + * + * input IntroduceShipPayload { + * clientMutationId: string! + * ship: Ship + * faction: Faction + * } + * + * type Mutation { + * introduceShip(input IntroduceShipInput!): IntroduceShipPayload + * } + */ + +/** + * We get the node interface and field from the relay library. + * + * The first method is the way we resolve an ID to its object. The second is the + * way we resolve an object that implements node to its type. + */ +var {nodeInterface, nodeField} = nodeDefinitions( + (globalId) => { + var {type, id} = fromGlobalId(globalId); + return data[type][id]; + }, + (obj) => { + return obj.ships ? factionType : shipType; + } +); + +/** + * We define our basic ship type. + * + * This implements the following type system shorthand: + * type Ship : Node { + * id: String! + * name: String + * } + */ +var shipType = new GraphQLObjectType({ + name: 'Ship', + description: 'A ship in the Star Wars saga', + fields: () => ({ + id: globalIdField('Ship'), + name: { + type: GraphQLString, + description: 'The name of the ship.', + }, + }), + interfaces: [nodeInterface] +}); + +/** + * We define a connection between a faction and its ships. + * + * connectionType implements the following type system shorthand: + * type ShipConnection { + * edges: [ShipEdge] + * pageInfo: PageInfo! + * } + * + * connectionType has an edges field - a list of edgeTypes that implement the + * following type system shorthand: + * type ShipEdge { + * cursor: String! + * node: Ship + * } + */ +var {connectionType: shipConnection} = + connectionDefinitions({name: 'Ship', nodeType: shipType}); + +/** + * We define our faction type, which implements the node interface. + * + * This implements the following type system shorthand: + * type Faction : Node { + * id: String! + * name: String + * ships: ShipConnection + * } + */ +var factionType = new GraphQLObjectType({ + name: 'Faction', + description: 'A faction in the Star Wars saga', + fields: () => ({ + id: globalIdField('Faction'), + name: { + type: GraphQLString, + description: 'The name of the faction.', + }, + ships: { + type: shipConnection, + description: 'The ships used by the faction.', + args: connectionArgs, + resolve: (faction, args) => connectionFromArray( + faction.ships.map((id) => data.Ship[id]), + args + ), + } + }), + interfaces: [nodeInterface] +}); + +/** + * This is the type that will be the root of our query, and the + * entry point into our schema. + * + * This implements the following type system shorthand: + * type Query { + * rebels: Faction + * empire: Faction + * node(id: String!): Node + * } + */ +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + rebels: { + type: factionType, + resolve: () => rebels, + }, + empire: { + type: factionType, + resolve: () => empire, + }, + node: nodeField + }) +}); + +/** + * This will return a GraphQLFieldConfig for our ship + * mutation. + * + * It creates these two types implicitly: + * input IntroduceShipInput { + * clientMutationId: string! + * shipName: string! + * factionId: ID! + * } + * + * input IntroduceShipPayload { + * clientMutationId: string! + * ship: Ship + * faction: Faction + * } + */ +var shipMutation = mutationWithClientMutationId({ + name: 'IntroduceShip', + inputFields: { + shipName: { + type: new GraphQLNonNull(GraphQLString) + }, + factionId: { + type: new GraphQLNonNull(GraphQLID) + } + }, + outputFields: { + ship: { + type: shipType, + resolve: (payload) => data['Ship'][payload.shipId] + }, + faction: { + type: factionType, + resolve: (payload) => data['Faction'][payload.factionId] + } + }, + mutateAndGetPayload: ({shipName, factionId}) => { + var newShip = { + id: getNewShipId(), + name: shipName + }; + data.Ship[newShip.id] = newShip; + data.Faction[factionId].ships.push(newShip.id); + return { + shipId: newShip.id, + factionId: factionId, + }; + } +}); + +/** + * This is the type that will be the root of our mutations, and the + * entry point into performing writes in our schema. + * + * This implements the following type system shorthand: + * type Mutation { + * introduceShip(input IntroduceShipInput!): IntroduceShipPayload + * } + */ +var mutationType = new GraphQLObjectType({ + name: 'Mutation', + fields: () => ({ + introduceShip: shipMutation + }) +}); + +/** + * Finally, we construct our schema (whose starting query type is the query + * type we defined above) and export it. + */ +export var StarWarsSchema = new GraphQLSchema({ + query: queryType, + mutation: mutationType +}); diff --git a/src/connection/__tests__/arrayconnection.js b/src/connection/__tests__/arrayconnection.js new file mode 100644 index 0000000..3dc0e0a --- /dev/null +++ b/src/connection/__tests__/arrayconnection.js @@ -0,0 +1,575 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { + connectionFromArray, + cursorForObjectInConnection, +} from '../arrayconnection'; + +var letters = ['A', 'B', 'C', 'D', 'E']; +describe('connectionFromArray', () => { + describe('Handles basic slicing', () => { + it('Returns all elements without filters', () => { + var c = connectionFromArray(letters, {}); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects a smaller first', () => { + var c = connectionFromArray(letters, {first: 2}); + return expect(c).to.deep.equal({ + edges: [ + { node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasPreviousPage: false, + hasNextPage: true, + } + }); + }); + + it('Respects an overly large first', () => { + var c = connectionFromArray(letters, {first: 10}); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects a smaller last', () => { + var c = connectionFromArray(letters, {last: 2}); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: true, + hasNextPage: false, + } + }); + }); + + it('Respects an overly large last', () => { + var c = connectionFromArray(letters, {last: 10}); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + }); + + describe('Handles pagination', () => { + it('Respects first and after', () => { + var c = connectionFromArray( + letters, + {first: 2, after: 'YXJyYXljb25uZWN0aW9uOjE='} + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + hasPreviousPage: false, + hasNextPage: true, + } + }); + }); + + it('Respects first and after with long first', () => { + var c = connectionFromArray( + letters, + {first: 10, after: 'YXJyYXljb25uZWN0aW9uOjE='} + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects last and before', () => { + var c = connectionFromArray( + letters, + {last: 2, before: 'YXJyYXljb25uZWN0aW9uOjM='} + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + hasPreviousPage: true, + hasNextPage: false, + } + }); + }); + + it('Respects last and before with long last', () => { + var c = connectionFromArray( + letters, + {last: 10, before: 'YXJyYXljb25uZWN0aW9uOjM='} + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects first and after and before, too few', () => { + var c = connectionFromArray( + letters, + { + first: 2, + after: 'YXJyYXljb25uZWN0aW9uOjA=', + before: 'YXJyYXljb25uZWN0aW9uOjQ=' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + hasPreviousPage: false, + hasNextPage: true, + } + }); + }); + + it('Respects first and after and before, too many', () => { + var c = connectionFromArray( + letters, + { + first: 4, + after: 'YXJyYXljb25uZWN0aW9uOjA=', + before: 'YXJyYXljb25uZWN0aW9uOjQ=' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects first and after and before, exactly right', () => { + var c = connectionFromArray( + letters, + { + first: 3, + after: 'YXJyYXljb25uZWN0aW9uOjA=', + before: 'YXJyYXljb25uZWN0aW9uOjQ=' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects last and after and before, too few', () => { + var c = connectionFromArray( + letters, + { + last: 2, + after: 'YXJyYXljb25uZWN0aW9uOjA=', + before: 'YXJyYXljb25uZWN0aW9uOjQ=' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + hasPreviousPage: true, + hasNextPage: false, + } + }); + }); + + it('Respects last and after and before, too many', () => { + var c = connectionFromArray( + letters, + { + last: 3, + after: 'YXJyYXljb25uZWN0aW9uOjA=', + before: 'YXJyYXljb25uZWN0aW9uOjQ=' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects last and after and before, exactly right', () => { + var c = connectionFromArray( + letters, + { + last: 3, + after: 'YXJyYXljb25uZWN0aW9uOjA=', + before: 'YXJyYXljb25uZWN0aW9uOjQ=' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + }); + + describe('Handles cursor edge cases', () => { + it('Returns all elements if cursors are invalid', () => { + var c = connectionFromArray( + letters, + {before: 'invalid', after: 'invalid'} + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Returns all elements if cursors are on the outside', () => { + var c = connectionFromArray( + letters, + { + before: 'YXJyYXljb25uZWN0aW9uOjYK', + after: 'YXJyYXljb25uZWN0aW9uOi0xCg==' + } + ); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Returns no elements if cursors cross', () => { + var c = connectionFromArray( + letters, + {before: 'YXJyYXljb25uZWN0aW9uOjI=', after: 'YXJyYXljb25uZWN0aW9uOjQ='} + ); + return expect(c).to.deep.equal({ + edges: [ + ], + pageInfo: { + startCursor: null, + endCursor: null, + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + }); + + describe('cursorForObjectInConnection', () => { + it('returns an edge\'s cursor, given an array and a member object', () => { + var letterBCursor = cursorForObjectInConnection(letters, 'B'); + return expect(letterBCursor).to.equal('YXJyYXljb25uZWN0aW9uOjE='); + }); + + it('returns null, given an array and a non-member object', () => { + var letterFCursor = cursorForObjectInConnection(letters, 'F'); + return expect(letterFCursor).to.be.null; + }); + }); +}); diff --git a/src/connection/__tests__/asyncarrayconnection.js b/src/connection/__tests__/asyncarrayconnection.js new file mode 100644 index 0000000..67cafed --- /dev/null +++ b/src/connection/__tests__/asyncarrayconnection.js @@ -0,0 +1,74 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { + connectionFromPromisedArray +} from '../arrayconnection'; + +var letters = Promise.resolve(['A', 'B', 'C', 'D', 'E']); +describe('connectionFromPromisedArray', () => { + it('Returns all elements without filters', async () => { + var c = await connectionFromPromisedArray(letters, {}); + return expect(c).to.deep.equal({ + edges: [ + { + node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + { + node: 'C', + cursor: 'YXJyYXljb25uZWN0aW9uOjI=', + }, + { + node: 'D', + cursor: 'YXJyYXljb25uZWN0aW9uOjM=', + }, + { + node: 'E', + cursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasPreviousPage: false, + hasNextPage: false, + } + }); + }); + + it('Respects a smaller first', async () => { + var c = await connectionFromPromisedArray(letters, {first: 2}); + return expect(c).to.deep.equal({ + edges: [ + { node: 'A', + cursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + { + node: 'B', + cursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + ], + pageInfo: { + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasPreviousPage: false, + hasNextPage: true, + } + }); + }); +}); diff --git a/src/connection/__tests__/connection.js b/src/connection/__tests__/connection.js new file mode 100644 index 0000000..b9e11e3 --- /dev/null +++ b/src/connection/__tests__/connection.js @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { + GraphQLInt, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphql +} from 'graphql'; + +import { + connectionFromArray +} from '../arrayconnection.js'; + +import { + connectionArgs, + connectionDefinitions +} from '../connection.js'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +var allUsers = [ + { name: 'Dan' }, + { name: 'Nick' }, + { name: 'Lee' }, + { name: 'Joe' }, + { name: 'Tim' }, +]; + +var userType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + name: { + type: GraphQLString, + }, + friends: { + type: friendConnection, + args: connectionArgs, + resolve: (user, args) => connectionFromArray(allUsers, args), + }, + }), +}); + +var {connectionType: friendConnection} = connectionDefinitions({ + name: 'Friend', + nodeType: userType, + edgeFields: () => ({ + friendshipTime: { + type: GraphQLString, + resolve: () => 'Yesterday' + } + }), + connectionFields: () => ({ + totalCount: { + type: GraphQLInt, + resolve: () => allUsers.length + } + }), +}); + +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + user: { + type: userType, + resolve: () => allUsers[0], + }, + }) +}); + +var schema = new GraphQLSchema({ + query: queryType, +}); + +describe('connectionDefinition tests', () => { + it('Includes connection and edge fields', async () => { + var query = ` + query FriendsQuery { + user { + friends(first: 2) { + totalCount + edges { + friendshipTime + node { + name + } + } + } + } + } + `; + var expected = { + user: { + friends: { + totalCount: 5, + edges: [ + { + friendshipTime: 'Yesterday', + node: { + name: 'Dan' + } + }, + { + friendshipTime: 'Yesterday', + node: { + name: 'Nick' + } + }, + ] + } + } + }; + var result = await graphql(schema, query); + expect(result).to.deep.equal({ data: expected }); + }); +}); diff --git a/src/connection/arrayconnection.js b/src/connection/arrayconnection.js new file mode 100644 index 0000000..f8b6d70 --- /dev/null +++ b/src/connection/arrayconnection.js @@ -0,0 +1,146 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import type { + Connection, + ConnectionArguments, + ConnectionCursor +} from './connectiontypes'; + +import { + base64, + unbase64 +} from '../utils/base64.js'; + +/** + * A simple function that accepts an array and connection arguments, and returns + * a connection object for use in GraphQL. It uses array offsets as pagination, + * so pagination will only work if the array is static. + */ +export function connectionFromArray( + data: Array, + args: ConnectionArguments +): Connection { + var edges = data.map( + (value, index) => { + return {cursor: offsetToCursor(index), node: value}; + } + ); + var {before, after, first, last} = args; + + // Slice with cursors + var begin = Math.max(getOffset(after, -1), -1) + 1; + var end = Math.min(getOffset(before, edges.length + 1), edges.length + 1); + edges = edges.slice(begin, end); + if (edges.length === 0) { + return emptyConnection(); + } + + // Save the pre-slice cursors + var firstPresliceCursor = edges[0].cursor; + var lastPresliceCursor = edges[edges.length - 1].cursor; + + // Slice with limits + if (first !== null && first !== undefined) { + edges = edges.slice(0, first); + } + if (last !== null && last !== undefined) { + edges = edges.slice(-last); + } + if (edges.length === 0) { + return emptyConnection(); + } + + // Construct the connection + var firstEdge = edges[0]; + var lastEdge = edges[edges.length - 1]; + return { + edges: edges, + pageInfo: { + startCursor: firstEdge.cursor, + endCursor: lastEdge.cursor, + hasPreviousPage: (firstEdge.cursor !== firstPresliceCursor), + hasNextPage: (lastEdge.cursor !== lastPresliceCursor) + } + }; +} + +/** + * A version of the above that takes a promised array, and returns a promised + * connection. + */ +export function connectionFromPromisedArray( + dataPromise: Promise>, + args: ConnectionArguments +): Promise> { + return dataPromise.then(data => connectionFromArray(data, args)); +} + +/** + * Helper to get an empty connection. + */ +function emptyConnection(): Connection { + return { + edges: [], + pageInfo: { + startCursor: null, + endCursor: null, + hasPreviousPage: false, + hasNextPage: false + } + }; +} + +var PREFIX = 'arrayconnection:'; + +/** + * Creates the cursor string from an offset. + */ +function offsetToCursor(offset: number): ConnectionCursor { + return base64(PREFIX + offset); +} + +/** + * Rederives the offset from the cursor string. + */ +function cursorToOffset(cursor: ConnectionCursor): number { + return parseInt(unbase64(cursor).substring(PREFIX.length), 10); +} + +/** + * Return the cursor associated with an object in an array. + */ +export function cursorForObjectInConnection( + data: Array, + object: T +): ?ConnectionCursor { + var offset = data.indexOf(object); + if (offset === -1) { + return null; + } + return offsetToCursor(offset); +} + +/** + * Given an optional cursor and a default offset, returns the offset + * to use; if the cursor contains a valid offset, that will be used, + * otherwise it will be the default. + */ +function getOffset(cursor?: ?ConnectionCursor, defaultOffset: number): number { + if (cursor === undefined || cursor === null) { + return defaultOffset; + } + var offset = cursorToOffset(cursor); + if (isNaN(offset)) { + return defaultOffset; + } + return offset; +} + diff --git a/src/connection/connection.js b/src/connection/connection.js new file mode 100644 index 0000000..7a4f1e7 --- /dev/null +++ b/src/connection/connection.js @@ -0,0 +1,124 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { + GraphQLBoolean, + GraphQLInt, + GraphQLNonNull, + GraphQLList, + GraphQLObjectType, + GraphQLString +} from 'graphql'; + +import type { + GraphQLFieldConfigArgumentMap, + GraphQLFieldConfigMap +} from 'graphql'; + +/** + * Returns a GraphQLFieldConfigArgumentMap appropriate to include + * on a field whose return type is a connection type. + */ +export var connectionArgs: GraphQLFieldConfigArgumentMap = { + before: { + type: GraphQLString + }, + after: { + type: GraphQLString + }, + first: { + type: GraphQLInt + }, + last: { + type: GraphQLInt + }, +}; + +type ConnectionConfig = { + name: string, + nodeType: GraphQLObjectType, + edgeFields?: ?(() => GraphQLFieldConfigMap) | ?GraphQLFieldConfigMap, + connectionFields?: ?(() => GraphQLFieldConfigMap) | ?GraphQLFieldConfigMap, +} + +function resolveMaybeThunk(thingOrThunk: T | () => T): T { + return typeof thingOrThunk === 'function' ? thingOrThunk() : thingOrThunk; +} + +/** + * Returns a GraphQLObjectType for a connection with the given name, + * and whose nodes are of the specified type. + */ +export function connectionDefinitions( + config: ConnectionConfig +): GraphQLObjectType { + var {name, nodeType} = config; + var edgeFields = config.edgeFields || {}; + var connectionFields = config.connectionFields || {}; + var edgeType = new GraphQLObjectType({ + name: name + 'Edge', + description: 'An edge in a connection.', + fields: () => ({ + node: { + type: nodeType, + description: 'The item at the end of the edge', + }, + cursor: { + type: new GraphQLNonNull(GraphQLString), + description: 'A cursor for use in pagination' + }, + ...resolveMaybeThunk(edgeFields) + }), + }); + + var connectionType = new GraphQLObjectType({ + name: name + 'Connection', + description: 'A connection to a list of items.', + fields: () => ({ + pageInfo: { + type: new GraphQLNonNull(pageInfoType), + description: 'Information to aid in pagination.' + }, + edges: { + type: new GraphQLList(edgeType), + description: 'Information to aid in pagination.' + }, + ...resolveMaybeThunk(connectionFields) + }), + }); + + return {edgeType, connectionType}; +} + +/** + * The common page info type used by all connections. + */ +var pageInfoType = new GraphQLObjectType({ + name: 'PageInfo', + description: 'Information about pagination in a connection.', + fields: () => ({ + hasNextPage: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'When paginating forwards, are there more items?' + }, + hasPreviousPage: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'When paginating backwards, are there more items?' + }, + startCursor: { + type: GraphQLString, + description: 'When paginating backwards, the cursor to continue.' + }, + endCursor: { + type: GraphQLString, + description: 'When paginating forwards, the cursor to continue.' + }, + }) +}); diff --git a/src/connection/connectiontypes.js b/src/connection/connectiontypes.js new file mode 100644 index 0000000..ffff5ec --- /dev/null +++ b/src/connection/connectiontypes.js @@ -0,0 +1,50 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * An flow type alias for cursors in this implementation. + */ +export type ConnectionCursor = string + +/** + * A flow type designed to be exposed as `PageInfo` over GraphQL. + */ +export type PageInfo = { + startCursor: ?ConnectionCursor, + endCursor: ?ConnectionCursor, + hasPreviousPage: ?boolean, + hasNextPage: ?boolean +} + +/** + * A flow type designed to be exposed as a `Connection` over GraphQL. + */ +export type Connection = { + edges: Array>; + pageInfo: PageInfo; +} + +/** + * A flow type designed to be exposed as a `Edge` over GraphQL. + */ +export type Edge = { + node: T; + cursor: ConnectionCursor; +} + +/** + * A flow type describing the arguments a connection field receives in GraphQL. + */ +export type ConnectionArguments = { + before?: ?ConnectionCursor; + after?: ?ConnectionCursor; + first?: ?number; + last?: ?number; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..591df39 --- /dev/null +++ b/src/index.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +// Helpers for creating connection types in the schema +export { + connectionArgs, + connectionDefinitions +} from './connection/connection.js'; + +// Helpers for creating connections from arrays +export { + connectionFromArray, + connectionFromPromisedArray, + cursorForObjectInConnection +} from './connection/arrayconnection.js'; + +// Helper for creating mutations with client mutation IDs +export { + mutationWithClientMutationId +} from './mutation/mutation.js'; + +// Helper for creating node definitions +export { + nodeDefinitions +} from './node/node.js'; + +// Utilities for creating global IDs in systems that don't have them. +export { + fromGlobalId, + toGlobalId, + globalIdField +} from './node/node.js'; diff --git a/src/mutation/__tests__/mutation.js b/src/mutation/__tests__/mutation.js new file mode 100644 index 0000000..bd6c125 --- /dev/null +++ b/src/mutation/__tests__/mutation.js @@ -0,0 +1,291 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +// 80+ char lines are useful in describe/it, so ignore in this file. +/*eslint-disable max-len */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import {GraphQLInt, GraphQLObjectType, GraphQLSchema, graphql} from 'graphql'; + +import { + mutationWithClientMutationId +} from '../mutation'; + +var simpleMutation = mutationWithClientMutationId({ + name: 'SimpleMutation', + inputFields: {}, + outputFields: { + result: { + type: GraphQLInt + } + }, + mutateAndGetPayload: () => ({result: 1}) +}); + +var simplePromiseMutation = mutationWithClientMutationId({ + name: 'SimplePromiseMutation', + inputFields: {}, + outputFields: { + result: { + type: GraphQLInt + } + }, + mutateAndGetPayload: () => Promise.resolve({result: 1}) +}); + +var mutation = new GraphQLObjectType({ + name: 'Mutation', + fields: { + simpleMutation: simpleMutation, + simplePromiseMutation: simplePromiseMutation + } +}); + +var schema = new GraphQLSchema({ + query: mutation, + mutation: mutation +}); + +describe('mutationWithClientMutationId', () => { + describe('Behaves correctly', () => { + it('Requires an argument', () => { + var query = ` + mutation M { + simpleMutation { + result + } + } + `; + var expected = { + errors: [{ + locations: [ + { + column: 11, + line: 3 + } + ], + message: 'Field \"simpleMutation\" argument \"input\" of type \"SimpleMutationInput!\" is required but not provided.', + }] + }; + return expect(graphql(schema, query)).to.become(expected); + }); + + it('Returns the same client mutation ID', () => { + var query = ` + mutation M { + simpleMutation(input: {clientMutationId: "abc"}) { + result + clientMutationId + } + } + `; + var expected = { + data: { + simpleMutation: { + result: 1, + clientMutationId: 'abc' + } + } + }; + return expect(graphql(schema, query)).to.become(expected); + }); + + it('Supports promise mutations', () => { + var query = ` + mutation M { + simplePromiseMutation(input: {clientMutationId: "abc"}) { + result + clientMutationId + } + } + `; + var expected = { + data: { + simplePromiseMutation: { + result: 1, + clientMutationId: 'abc' + } + } + }; + return expect(graphql(schema, query)).to.become(expected); + }); + }); + + describe('Introspects correctly', () => { + it('Contains correct input', () => { + var query = `{ + __type(name: "SimpleMutationInput") { + name + kind + inputFields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + }`; + var expected = { + __type: { + name: 'SimpleMutationInput', + kind: 'INPUT_OBJECT', + inputFields: [ + { + name: 'clientMutationId', + type: { + name: null, + kind: 'NON_NULL', + ofType: { + name: 'String', + kind: 'SCALAR' + } + } + } + ] + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Contains correct payload', () => { + var query = `{ + __type(name: "SimpleMutationPayload") { + name + kind + fields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + }`; + var expected = { + __type: { + name: 'SimpleMutationPayload', + kind: 'OBJECT', + fields: [ + { + name: 'result', + type: { + name: 'Int', + kind: 'SCALAR', + ofType: null + } + }, + { + name: 'clientMutationId', + type: { + name: null, + kind: 'NON_NULL', + ofType: { + name: 'String', + kind: 'SCALAR' + } + } + } + ] + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Contains correct field', () => { + var query = `{ + __schema { + mutationType { + fields { + name + args { + name + type { + name + kind + ofType { + name + kind + } + } + } + type { + name + kind + } + } + } + } + }`; + var expected = { + __schema: { + mutationType: { + fields: [ + { + name: 'simpleMutation', + args: [ + { + name: 'input', + type: { + name: null, + kind: 'NON_NULL', + ofType: { + name: 'SimpleMutationInput', + kind: 'INPUT_OBJECT' + } + }, + } + ], + type: { + name: 'SimpleMutationPayload', + kind: 'OBJECT', + } + }, + { + name: 'simplePromiseMutation', + args: [ + { + name: 'input', + type: { + name: null, + kind: 'NON_NULL', + ofType: { + name: 'SimplePromiseMutationInput', + kind: 'INPUT_OBJECT' + } + }, + } + ], + type: { + name: 'SimplePromiseMutationPayload', + kind: 'OBJECT', + } + }, + ] + } + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + }); +}); diff --git a/src/mutation/mutation.js b/src/mutation/mutation.js new file mode 100644 index 0000000..d35d2ac --- /dev/null +++ b/src/mutation/mutation.js @@ -0,0 +1,90 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { + GraphQLInputObjectType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString +} from 'graphql'; + +import type { + GraphQLFieldConfig, + GraphQLFieldConfigMap +} from 'graphql'; + +type mutationFn = (object: Object) => Object | + (object: Object) => Promise; + +/** + * A description of a mutation consumable by mutationWithClientMutationId + * to create a GraphQLFieldConfig for that mutation. + * + * The inputFields and outputFields should not include `clientMutationId`, + * as this will be provided automatically. + * + * An input object will be created containing the input fields, and an + * object will be created containing the output fields. + * + * mutateAndGetPayload will receieve an Object with a key for each + * input field, and it should return an Object with a key for each + * output field. It may return synchronously, or return a Promise. + */ +type MutationConfig = { + name: string, + inputFields: GraphQLFieldConfigMap, + outputFields: GraphQLFieldConfigMap, + mutateAndGetPayload: mutationFn, +} + +/** + * Returns a GraphQLFieldConfig for the mutation described by the + * provided MutationConfig. + */ +export function mutationWithClientMutationId( + config: MutationConfig +): GraphQLFieldConfig { + var {name, inputFields, outputFields, mutateAndGetPayload} = config; + var augmentedInputFields = { + ...inputFields, + clientMutationId: { + type: new GraphQLNonNull(GraphQLString) + } + }; + var augmentedOutputFields = { + ...outputFields, + clientMutationId: { + type: new GraphQLNonNull(GraphQLString) + } + }; + + var outputType = new GraphQLObjectType({ + name: name + 'Payload', + fields: augmentedOutputFields + }); + + var inputType = new GraphQLInputObjectType({ + name: name + 'Input', + fields: augmentedInputFields + }); + + return { + type: outputType, + args: { + input: {type: new GraphQLNonNull(inputType)} + }, + resolve: (_, {input}) => { + return Promise.resolve(mutateAndGetPayload(input)).then(payload => { + payload.clientMutationId = input.clientMutationId; + return payload; + }); + } + }; +} diff --git a/src/node/__tests__/global.js b/src/node/__tests__/global.js new file mode 100644 index 0000000..5d04516 --- /dev/null +++ b/src/node/__tests__/global.js @@ -0,0 +1,165 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { + GraphQLInt, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphql +} from 'graphql'; + +import { + fromGlobalId, + globalIdField, + nodeDefinitions, +} from '../node'; + +var userData = { + '1': { // eslint-disable-line quote-props + id: 1, + name: 'John Doe' + }, + '2': { // eslint-disable-line quote-props + id: 2, + name: 'Jane Smith' + }, +}; + +var photoData = { + '1': { // eslint-disable-line quote-props + photoId: 1, + width: 300 + }, + '2': { // eslint-disable-line quote-props + photoId: 2, + width: 400 + }, +}; + +var {nodeField, nodeInterface} = nodeDefinitions( + (globalId) => { + var {type, id} = fromGlobalId(globalId); + if (type === 'User') { + return userData[id]; + } + if (type === 'Photo') { + return photoData[id]; + } + return null; + }, + (obj) => { + if (obj.id) { + return userType; + } + if (obj.photoId) { + return photoType; + } + return null; + } +); + +var userType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + id: globalIdField('User'), + name: { + type: GraphQLString, + }, + }), + interfaces: [nodeInterface] +}); + +var photoType = new GraphQLObjectType({ + name: 'Photo', + fields: () => ({ + id: globalIdField('Photo', (obj) => obj.photoId), + width: { + type: GraphQLInt, + }, + }), + interfaces: [nodeInterface] +}); + +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + node: nodeField, + allObjects: { + type: new GraphQLList(nodeInterface), + resolve: () => [userData[1], userData[2], photoData[1], photoData[2]] + } + }) +}); + +var schema = new GraphQLSchema({ + query: queryType +}); + +describe('Global ID fields', () => { + it('Gives different IDs', () => { + var query = `{ + allObjects { + id + } + }`; + var expected = { + allObjects: [ + { + id: 'VXNlcjox' + }, + { + id: 'VXNlcjoy' + }, + { + id: 'UGhvdG86MQ==' + }, + { + id: 'UGhvdG86Mg==' + }, + ] + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Refetches the IDs', () => { + var query = `{ + user: node(id: "VXNlcjox") { + id + ... on User { + name + } + }, + photo: node(id: "UGhvdG86MQ==") { + id + ... on Photo { + width + } + } + }`; + var expected = { + user: { + id: 'VXNlcjox', + name: 'John Doe' + }, + photo: { + id: 'UGhvdG86MQ==', + width: 300 + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); +}); diff --git a/src/node/__tests__/node.js b/src/node/__tests__/node.js new file mode 100644 index 0000000..90e385a --- /dev/null +++ b/src/node/__tests__/node.js @@ -0,0 +1,339 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { + GraphQLID, + GraphQLInt, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphql +} from 'graphql'; + +import { + nodeDefinitions +} from '../node'; + +var userData = { + '1': { // eslint-disable-line quote-props + id: 1, + name: 'John Doe' + }, + '2': { // eslint-disable-line quote-props + id: 2, + name: 'Jane Smith' + }, +}; + +var photoData = { + '3': { // eslint-disable-line quote-props + id: 3, + width: 300 + }, + '4': { // eslint-disable-line quote-props + id: 4, + width: 400 + }, +}; + +var {nodeField, nodeInterface} = nodeDefinitions( + (id) => { + if (userData[id]) { + return userData[id]; + } + if (photoData[id]) { + return photoData[id]; + } + return null; + }, + (obj) => { + if (userData[obj.id]) { + return userType; + } + if (photoData[obj.id]) { + return photoType; + } + return null; + } +); + +var userType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLID), + }, + name: { + type: GraphQLString, + }, + }), + interfaces: [nodeInterface] +}); + +var photoType = new GraphQLObjectType({ + name: 'Photo', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLID), + }, + width: { + type: GraphQLInt, + }, + }), + interfaces: [nodeInterface] +}); + +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + node: nodeField + }) +}); + +var schema = new GraphQLSchema({ + query: queryType +}); + +describe('Node interface and fields', () => { + describe('Allows refetching', () => { + it('Gets the correct ID for users', () => { + var query = `{ + node(id: "1") { + id + } + }`; + var expected = { + node: { + id: '1', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Gets the correct ID for photos', () => { + var query = `{ + node(id: "4") { + id + } + }`; + var expected = { + node: { + id: '4', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Gets the correct name for users', () => { + var query = `{ + node(id: "1") { + id + ... on User { + name + } + } + }`; + var expected = { + node: { + id: '1', + name: 'John Doe', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Gets the correct width for photos', () => { + var query = `{ + node(id: "4") { + id + ... on Photo { + width + } + } + }`; + var expected = { + node: { + id: '4', + width: 400, + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Gets the correct type name for users', () => { + var query = `{ + node(id: "1") { + id + __typename + } + }`; + var expected = { + node: { + id: '1', + __typename: 'User', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Gets the correct width for photos', () => { + var query = `{ + node(id: "4") { + id + __typename + } + }`; + var expected = { + node: { + id: '4', + __typename: 'Photo', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Ignores photo fragments on user', () => { + var query = `{ + node(id: "1") { + id + ... on Photo { + width + } + } + }`; + var expected = { + node: { + id: '1', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Returns null for bad IDs', () => { + var query = `{ + node(id: "5") { + id + } + }`; + var expected = { + node: null + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + }); + + describe('Correctly introspects', () => { + it('Has correct node interface', () => { + var query = `{ + __type(name: "Node") { + name + kind + fields { + name + type { + kind + ofType { + name + kind + } + } + } + } + }`; + var expected = { + __type: { + name: 'Node', + kind: 'INTERFACE', + fields: [ + { + name: 'id', + type: { + kind: 'NON_NULL', + ofType: { + name: 'ID', + kind: 'SCALAR' + } + } + } + ] + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Has correct node root field', () => { + var query = `{ + __schema { + queryType { + fields { + name + type { + name + kind + } + args { + name + type { + kind + ofType { + name + kind + } + } + } + } + } + } + }`; + var expected = { + __schema: { + queryType: { + fields: [ + { + name: 'node', + type: { + name: 'Node', + kind: 'INTERFACE' + }, + args: [ + { + name: 'id', + type: { + kind: 'NON_NULL', + ofType: { + name: 'ID', + kind: 'SCALAR' + } + } + } + ] + } + ] + } + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + }); +}); diff --git a/src/node/__tests__/nodeasync.js b/src/node/__tests__/nodeasync.js new file mode 100644 index 0000000..5e2414d --- /dev/null +++ b/src/node/__tests__/nodeasync.js @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphql +} from 'graphql'; + +import { + nodeDefinitions +} from '../node'; + +var userData = { + 1: { + id: 1, + name: 'John Doe' + }, + 2: { + id: 2, + name: 'Jane Smith' + }, +}; + +var {nodeField, nodeInterface} = nodeDefinitions( + async (id) => { + if (userData[id]) { + return userData[id]; + } + return null; + }, + (obj) => { + if (userData[obj.id]) { + return userType; + } + return null; + } +); + +var userType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLString), + }, + name: { + type: GraphQLString, + }, + }), + interfaces: [nodeInterface] +}); + +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + node: nodeField + }) +}); + +var schema = new GraphQLSchema({ + query: queryType +}); + +describe('Node interface and fields with async object fetcher', () => { + it('Gets the correct ID for users', () => { + var query = `{ + node(id: "1") { + id + } + }`; + var expected = { + node: { + id: '1', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Gets the correct name for users', () => { + var query = `{ + node(id: "1") { + id + ... on User { + name + } + } + }`; + var expected = { + node: { + id: '1', + name: 'John Doe', + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); +}); diff --git a/src/node/__tests__/plural.js b/src/node/__tests__/plural.js new file mode 100644 index 0000000..fa38452 --- /dev/null +++ b/src/node/__tests__/plural.js @@ -0,0 +1,157 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +import { + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphql +} from 'graphql'; + +import { + pluralIdentifyingRootField +} from '../plural'; + +var userType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + username: { + type: GraphQLString, + }, + url: { + type: GraphQLString, + }, + }), +}); + +var queryType = new GraphQLObjectType({ + name: 'Query', + fields: () => ({ + usernames: pluralIdentifyingRootField({ + argName: 'usernames', + description: 'Map from a username to the user', + inputType: GraphQLString, + outputType: userType, + resolveSingleInput: (username) => ({ + username: username, + url: 'www.facebook.com/' + username + }) + }) + }) +}); + +var schema = new GraphQLSchema({ + query: queryType +}); + +describe('pluralIdentifyingRootField', () => { + it('Allows fetching', () => { + var query = `{ + usernames(usernames:["dschafer", "leebyron", "schrockn"]) { + username + url + } + }`; + var expected = { + usernames: [ + { + username: 'dschafer', + url: 'www.facebook.com/dschafer' + }, + { + username: 'leebyron', + url: 'www.facebook.com/leebyron' + }, + { + username: 'schrockn', + url: 'www.facebook.com/schrockn' + }, + ] + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); + + it('Correctly introspects', () => { + var query = `{ + __schema { + queryType { + fields { + name + args { + name + type { + kind + ofType { + kind + ofType { + kind + ofType { + name + kind + } + } + } + } + } + type { + kind + ofType { + name + kind + } + } + } + } + } + }`; + var expected = { + __schema: { + queryType: { + fields: [ + { + name: 'usernames', + args: [ + { + name: 'usernames', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'LIST', + ofType: { + kind: 'NON_NULL', + ofType: { + name: 'String', + kind: 'SCALAR', + } + } + } + } + } + ], + type: { + kind: 'LIST', + ofType: { + name: 'User', + kind: 'OBJECT', + } + } + } + ] + } + } + }; + + return expect(graphql(schema, query)).to.become({data: expected}); + }); +}); diff --git a/src/node/node.js b/src/node/node.js new file mode 100644 index 0000000..5d7582e --- /dev/null +++ b/src/node/node.js @@ -0,0 +1,114 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { + GraphQLInterfaceType, + GraphQLNonNull, + GraphQLID +} from 'graphql'; + +import type { + GraphQLFieldConfig, + GraphQLObjectType +} from 'graphql'; + +import { + base64, + unbase64 +} from '../utils/base64.js'; + +type GraphQLNodeDefinitions = { + nodeInterface: GraphQLInterfaceType, + nodeField: GraphQLFieldConfig +} + +type typeResolverFn = (object: any) => ?GraphQLObjectType | + (object: any) => ?Promise; + +/** + * Given a function to map from an ID to an underlying object, and a function + * to map from an underlying object to the concrete GraphQLObjectType it + * corresponds to, constructs a `Node` interface that objects can implement, + * and a field config for a `node` root field. + */ +export function nodeDefinitions( + idFetcher: ((id: string) => any), + typeResolver: typeResolverFn +): GraphQLNodeDefinitions { + var nodeInterface = new GraphQLInterfaceType({ + name: 'Node', + description: 'An object with an ID', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLID), + description: 'The id of the object.', + }, + }), + resolveType: typeResolver + }); + + var nodeField = { + name: 'node', + description: 'Fetches an object given its ID', + type: nodeInterface, + args: { + id: { + type: new GraphQLNonNull(GraphQLID), + description: 'The ID of an object' + } + }, + resolve: (obj, {id}) => idFetcher(id) + }; + + return {nodeInterface, nodeField}; +} + +type ResolvedGlobalId = { + type: string, + id: string +} + +/** + * Takes a type name and an ID specific to that type name, and returns a + * "global ID" that is unique among all types. + */ +export function toGlobalId(type: string, id: string): string { + return base64([type, id].join(':')); +} + +/** + * Takes the "global ID" created by toGlobalID, and retuns the type name and ID + * used to create it. + */ +export function fromGlobalId(globalId: string): ResolvedGlobalId { + var tokens = unbase64(globalId).split(':', 2); + return { + type: tokens[0], + id: tokens[1] + }; +} + +/** + * Creates the configuration for an id field on a node, using `toGlobalId` to + * construct the ID from the provided typename. The type-specific ID is fetcher + * by calling idFetcher on the object, or if not provided, by accessing the `id` + * property on the object. + */ +export function globalIdField( + typeName: string, + idFetcher?: (object: any) => string +): GraphQLFieldConfig { + return { + name: 'id', + description: 'The ID of an object', + type: new GraphQLNonNull(GraphQLID), + resolve: (obj) => toGlobalId(typeName, idFetcher ? idFetcher(obj) : obj.id) + }; +} diff --git a/src/node/plural.js b/src/node/plural.js new file mode 100644 index 0000000..b90e7d7 --- /dev/null +++ b/src/node/plural.js @@ -0,0 +1,54 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { + GraphQLList, + GraphQLNonNull, +} from 'graphql'; + +import type { + GraphQLFieldConfig, + GraphQLInputType, + GraphQLOutputType, +} from 'graphql'; + +type PluralIdentifyingRootFieldConfig = { + argName: string, + inputType: GraphQLInputType, + outputType: GraphQLOutputType, + resolveSingleInput: (input: any) => ?any, + description?: ?string, +}; + +export function pluralIdentifyingRootField( + config: PluralIdentifyingRootFieldConfig +): GraphQLFieldConfig { + var inputArgs = {}; + inputArgs[config.argName] = { + type: new GraphQLNonNull( + new GraphQLList( + new GraphQLNonNull( + config.inputType + ) + ) + ) + }; + return { + description: config.description, + type: new GraphQLList(config.outputType), + args: inputArgs, + resolve: (obj, args) => { + var inputs = args[config.argName]; + return Promise.all(inputs.map( + input => Promise.resolve(config.resolveSingleInput(input)) + )); + } + }; +} diff --git a/src/utils/base64.js b/src/utils/base64.js new file mode 100644 index 0000000..2693d6f --- /dev/null +++ b/src/utils/base64.js @@ -0,0 +1,19 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +export type Base64String = string; + +export function base64(i: string): Base64String { + return ((new Buffer(i, 'ascii')).toString('base64')); +} + +export function unbase64(i: Base64String): string { + return ((new Buffer(i, 'base64')).toString('ascii')); +}