├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── fromConnection.js ├── index.js ├── isConnection.js ├── pageInfoHeaders.js ├── strToBool.js └── toConnection.js └── test └── index-test.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "indent": [2, 2], 9 | "brace-style": [2, "1tbs"], 10 | "comma-style": [2, "last"], 11 | "curly": [2, "multi-line"], 12 | "quotes": [2, "single"], 13 | "strict": 0, 14 | "camelcase": 0, 15 | "no-debugger": 2, 16 | "no-console": 2, 17 | "no-undef": 2, 18 | "no-underscore-dangle": 0, 19 | "no-mixed-requires": 0, 20 | "no-use-before-define": 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | coverage 4 | *.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | coverage 3 | *.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 0.10 5 | - 4.1 6 | 7 | branches: 8 | only: 9 | - master 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dow Jones & Company 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL REST Connections 2 | [![Build Status](https://secure.travis-ci.org/dowjones/graphql-rest-connections.png)](http://travis-ci.org/dowjones/graphql-rest-connections) [![NPM version](https://badge.fury.io/js/graphql-rest-connections.svg)](http://badge.fury.io/js/graphql-rest-connections) 3 | 4 | This library helps with pagination in GraphQL, when backed by REST services. 5 | 6 | It will convert a Connection object, as defined by the 7 | [Cursor Connection Specification](https://facebook.github.io/relay/graphql/connections.htm): 8 | 9 | ```json 10 | { 11 | "edges": [{ 12 | "cursor": "d02dsf", 13 | "node": {"id": "56", "name": "mary"} 14 | }, { 15 | "cursor": "b8df4=", 16 | "node": {"id": "78", "name": "joe"} 17 | }], 18 | "pageInfo": { 19 | "startCursor": "d02dsf", 20 | "endCursor": "b8df4=", 21 | "hasNextPage": true, 22 | "hasPreviousPage": false 23 | } 24 | } 25 | ``` 26 | 27 | and turn it into a list of headers and nodes, so that 28 | it could easily be returned by a RESTful service: 29 | 30 | ``` 31 | GET /users 32 | Content-Type: application/json 33 | x-pageinfo-start-cursor: d02dsf 34 | x-pageinfo-end-cursor: b8df4= 35 | x-pageinfo-has-previous-page: true 36 | x-pageinfo-has-next-page: false 37 | x-pageinfo-cursors: d02dsf,b8df4= 38 | 39 | [ 40 | {"id": "56", "name": "mary"}, 41 | {"id": "78", "name": "joe"} 42 | ] 43 | ``` 44 | 45 | Note that the returned objects is a list of resources that are 46 | exactly the same as ones you would get from `/users/78` (for example). 47 | The pagination info goes inside the `pageinfo` headers. 48 | 49 | 50 | ## Usage 51 | 52 | - `isConnection(object)` -- determines whether the object is a Connection 53 | - `fromConnection(connection)` -- returns `{nodes: [], headers: []}` 54 | - `toConnection(nodes, headers)` -- returns a `connection` 55 | 56 | 57 | In a REST service: 58 | 59 | ```js 60 | app.get('/users', (req, res, next) => { 61 | service.getUserConnections(req.query) 62 | .catch(next) 63 | .then(connection => { 64 | const {nodes, headers} = fromConnection(connection); 65 | res.set(headers); 66 | res.json(nodes); 67 | }); 68 | }); 69 | ``` 70 | 71 | In GraphQL service that is backed by the REST service above: 72 | 73 | ```js 74 | const userConnections = { 75 | type: userConnection, 76 | args: connectionArgs, 77 | resolve: ((_, args) => { 78 | requestPromise.get({ 79 | uri: USER_SERVICE_URI, 80 | qs: args, 81 | json: true, 82 | resolveWithFullResponse: true 83 | }) 84 | .then(res => toConnection(res.body, res.headers)); 85 | }) 86 | }; 87 | ``` 88 | 89 | ## Related 90 | 91 | [GraphQL DynamoDB Connections](https://github.com/dowjones/graphql-dynamodb-connections) 92 | 93 | 94 | ## License 95 | 96 | [MIT](/LICENSE) 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-rest-connections", 3 | "version": "1.0.0", 4 | "description": "This library helps with pagination in GraphQL, when backed by REST services.", 5 | "keywords": [ 6 | "graphql", 7 | "rest", 8 | "connections", 9 | "pagination" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/dowjones/graphql-rest-connections.git" 14 | }, 15 | "author": "nemtsov@gmail.com", 16 | "main": "lib/index", 17 | "main-es6": "src/index", 18 | "license": "MIT", 19 | "scripts": { 20 | "prepublish": "babel src -d lib --optional runtime", 21 | "lint": "eslint src test", 22 | "test": "npm run lint && npm run test-cover && npm run test-check-coverage", 23 | "test-watch": "babel-node ./node_modules/.bin/_mocha --require should --recursive --reporter min --watch", 24 | "test-cover": "babel-node ./node_modules/.bin/babel-istanbul cover _mocha -- --require should --recursive", 25 | "test-check-coverage": "babel-istanbul check-coverage --statements 100 --functions 100 --branches 100 --lines 100" 26 | }, 27 | "dependencies": {}, 28 | "devDependencies": { 29 | "babel-cli": "^6.4.0", 30 | "babel-eslint": "^5.0.0-beta6", 31 | "babel-istanbul": "^0.6.0", 32 | "babel-plugin-transform-runtime": "^6.4.3", 33 | "babel-preset-es2015": "^6.3.13", 34 | "babel-preset-stage-0": "^6.3.13", 35 | "babel-register": "^6.4.3", 36 | "eslint": "^1.10.3", 37 | "istanbul": "^0.4.2", 38 | "mocha": "^2.3.4", 39 | "should": "^8.1.1" 40 | }, 41 | "babel": { 42 | "presets": [ 43 | "es2015", 44 | "stage-0" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/fromConnection.js: -------------------------------------------------------------------------------- 1 | import PAGEINFO from './pageInfoHeaders'; 2 | 3 | export function fromConnection(conn) { 4 | const {nodes, cursors} = getNodesAndCursors(conn); 5 | const pageInfoHeaders = pageInfoToHeaders(conn.pageInfo); 6 | const cursorHeaders = cursorsToHeader(cursors); 7 | const headers = {...pageInfoHeaders, ...cursorHeaders}; 8 | return {nodes, headers}; 9 | } 10 | 11 | function getNodesAndCursors(conn) { 12 | const out = {nodes: [], cursors: []}; 13 | conn.edges.forEach(conn => { 14 | out.nodes.push(conn.node); 15 | out.cursors.push(conn.cursor); 16 | }); 17 | return out; 18 | } 19 | 20 | function pageInfoToHeaders(pageInfo) { 21 | return { 22 | [PAGEINFO.startCursor]: pageInfo.startCursor, 23 | [PAGEINFO.endCursor]: pageInfo.endCursor, 24 | [PAGEINFO.hasPreviousPage]: pageInfo.hasPreviousPage, 25 | [PAGEINFO.hasNextPage]: pageInfo.hasNextPage 26 | }; 27 | } 28 | 29 | function cursorsToHeader(cursors) { 30 | return { 31 | [PAGEINFO.cursors]: cursors.join() 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './isConnection'; 2 | export * from './fromConnection'; 3 | export * from './toConnection'; 4 | -------------------------------------------------------------------------------- /src/isConnection.js: -------------------------------------------------------------------------------- 1 | export function isConnection(obj={}) { 2 | return (typeof obj.pageInfo === 'object') && 3 | Array.isArray(obj.edges); 4 | } 5 | -------------------------------------------------------------------------------- /src/pageInfoHeaders.js: -------------------------------------------------------------------------------- 1 | export default { 2 | startCursor: 'x-pageinfo-start-cursor', 3 | endCursor: 'x-pageinfo-end-cursor', 4 | hasPreviousPage: 'x-pageinfo-has-previous-page', 5 | hasNextPage: 'x-pageinfo-has-next-page', 6 | cursors: 'x-pageinfo-cursors' 7 | }; 8 | -------------------------------------------------------------------------------- /src/strToBool.js: -------------------------------------------------------------------------------- 1 | export default function strToBool(str) { 2 | if (typeof str === 'boolean') return str; 3 | return str === 'true'; 4 | } 5 | -------------------------------------------------------------------------------- /src/toConnection.js: -------------------------------------------------------------------------------- 1 | import PAGEINFO from './pageInfoHeaders'; 2 | import strToBool from './strToBool'; 3 | 4 | export function toConnection(nodes, headers) { 5 | return { 6 | pageInfo: headersToPageInfo(headers), 7 | edges: wrapNodesInEdges(nodes, headers) 8 | }; 9 | } 10 | 11 | function headersToPageInfo(headers) { 12 | return { 13 | startCursor: headers[PAGEINFO.startCursor], 14 | endCursor: headers[PAGEINFO.endCursor], 15 | hasPreviousPage: strToBool(headers[PAGEINFO.hasPreviousPage]), 16 | hasNextPage: strToBool(headers[PAGEINFO.hasNextPage]) 17 | }; 18 | } 19 | 20 | function wrapNodesInEdges(nodes, headers) { 21 | const cursors = headers[PAGEINFO.cursors].split(','); 22 | return nodes.map((node, index) => ({ 23 | cursor: cursors[index], 24 | node 25 | })); 26 | } 27 | -------------------------------------------------------------------------------- /test/index-test.js: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | import PAGEINFO from '../src/pageInfoHeaders'; 3 | import { 4 | isConnection, 5 | fromConnection, 6 | toConnection 7 | } from '../src'; 8 | 9 | describe('dynamodb-rest-connections', () => { 10 | describe('isConnection', () => { 11 | it('should detect a non-conn', () => { 12 | should(isConnection()).not.be.ok(); 13 | }); 14 | 15 | it('should detect a conn', () => { 16 | should(isConnection({pageInfo: {}, edges: []})).be.ok(); 17 | }); 18 | }); 19 | 20 | it('should complement', () => { 21 | const connection = { 22 | edges: [ 23 | {cursor: 'd02dsf', node: {id: '56', name: 'mary'}}, 24 | {cursor: 'b8df4=', node: {id: '78', name: 'joe'}} 25 | ], 26 | pageInfo: { 27 | startCursor: 'd02dsf', 28 | endCursor: 'b8df4=', 29 | hasNextPage: true, 30 | hasPreviousPage: false 31 | } 32 | }; 33 | 34 | const {nodes, headers} = fromConnection(connection); 35 | const resultingConnection = toConnection(nodes, headers); 36 | 37 | resultingConnection.should.eql(connection); 38 | }); 39 | 40 | describe('toConnection', () => { 41 | it('should work with string-boolean headers', () => { 42 | const conn = toConnection([], { 43 | [PAGEINFO.hasNextPage]: 'false', 44 | [PAGEINFO.hasPreviousPage]: 'true', 45 | [PAGEINFO.cursors]: '' 46 | }); 47 | should(conn.pageInfo.hasPreviousPage).be.true(); 48 | should(conn.pageInfo.hasNextPage).be.false(); 49 | }); 50 | }); 51 | }); 52 | --------------------------------------------------------------------------------