├── .eslintrc.json ├── .gitignore ├── .istanbul.yml ├── .nvmrc ├── .travis.yml ├── README.md ├── demo ├── .eslintrc.json ├── .gitignore ├── demo-data.json ├── demo-space-graph.png ├── package.json ├── prepare-demo-data.js ├── server.js ├── src │ ├── App.js │ ├── favicon.png │ ├── index.css │ ├── index.html │ ├── index.js │ └── logo.png └── webpack.config.js ├── dev └── server.js ├── package.json ├── src ├── backref-types.js ├── base-types.js ├── client.js ├── entry-loader.js ├── field-config.js ├── helpers │ ├── express-graphql-extension.js │ ├── graphiql.js │ └── index.js ├── http-client.js ├── index.js ├── prepare-space-graph.js └── schema.js └── test ├── backref-types.test.js ├── base-types.test.js ├── client.test.js ├── entry-loader.test.js ├── field-config.test.js ├── helpers ├── express-graphql-extension.test.js ├── graphiql.test.js └── index.test.js ├── http-client.test.js ├── index.test.js ├── prepare-space-graph.test.js └── schema.test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "indent": [ 10 | "error", 11 | 2, 12 | { "MemberExpression": 0 } 13 | ], 14 | "linebreak-style": [ 15 | "error", 16 | "unix" 17 | ], 18 | "quotes": [ 19 | "error", 20 | "single" 21 | ], 22 | "semi": [ 23 | "error", 24 | "always" 25 | ], 26 | "strict": [ 27 | "error", 28 | "global" 29 | ], 30 | "no-console": [ 31 | "off" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /dump 4 | /dev/config.json 5 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | instrumentation: 2 | include-all-sources: true 3 | excludes: 4 | - demo/** 5 | - dev/** 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 6.11.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | install: 3 | - npm install 4 | script: 5 | - npm run lint 6 | - npm run test 7 | after_success: 8 | - npm run coverage 9 | - npm run codecov 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cf-graphql 2 | 3 | [![travis build status](https://img.shields.io/travis/contentful-labs/cf-graphql.svg)](https://travis-ci.org/contentful-labs/cf-graphql) 4 | [![npm version](https://img.shields.io/npm/v/cf-graphql.svg)](https://www.npmjs.com/package/cf-graphql) 5 | [![npm downloads](https://img.shields.io/npm/dt/cf-graphql.svg)](https://www.npmjs.com/package/cf-graphql) 6 | [![deps status](https://img.shields.io/david/contentful-labs/cf-graphql.svg)](https://david-dm.org/contentful-labs/cf-graphql) 7 | [![dev deps status](https://img.shields.io/david/dev/contentful-labs/cf-graphql.svg)](https://david-dm.org/contentful-labs/cf-graphql?type=dev) 8 | [![codecov coverage](https://img.shields.io/codecov/c/github/contentful-labs/cf-graphql.svg)](https://codecov.io/gh/contentful-labs/cf-graphql) 9 | 10 | `cf-graphql` is a library that allows you to query your data stored in [Contentful](https://www.contentful.com/) with [GraphQL](http://graphql.org/). A schema and value resolvers are automatically generated out of an existing space. 11 | 12 | Generated artifacts can be used with any node-based GraphQL server. The outcome of the project's main function call is an instance of the [`GraphQLSchema`](http://graphql.org/graphql-js/type/#graphqlschema) class. 13 | 14 | 15 | ## Table of contents 16 | 17 | - [Disclaimers](#disclaimers) 18 | - [First steps](#first-steps) 19 | - [Demo](#demo) 20 | - [Run it locally](#run-it-locally) 21 | - [Deploy to Zeit's now](#deploy-to-zeits-now) 22 | - [Programmatic usage](#programmatic-usage) 23 | - [Querying](#querying) 24 | - [Helpers](#helpers) 25 | - [Contributing](#contributing) 26 | 27 | 28 | ## Disclaimers 29 | 30 | Please note that `cf-graphql` library is released as an experiment: 31 | 32 | - we might introduce breaking changes into programmatic interfaces and space querying approach before v1.0 is released 33 | - there’s no magic bullet: complex GraphQL queries can result in a large number of CDA calls, which will be counted against your quota 34 | - we might discontinue development of the library and stop maintaining it 35 | 36 | 37 | ## First steps 38 | 39 | If you just want to see how it works, please follow the [Demo](#demo) section. You can deploy the demo with your own credentials so it queries your own data. 40 | 41 | In general `cf-graphql` is a library and it can be used as a part of your project. If you want to get your hands dirty coding, follow the [Programmatic usage](#programmatic-usage) section. 42 | 43 | 44 | ## Demo 45 | 46 | We host an [online demo](https://cf-graphql-demo.now.sh/) for you. You can query Contentful's "Blog" space template there. This how its graph looks like: 47 | 48 | ![Demo space graph](./demo/demo-space-graph.png) 49 | 50 | 51 | ### Run it locally 52 | 53 | This repository contains a demo project. The demo comes with a web server (with [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) enabled) providing the GraphQL, [an in-browser IDE (GraphiQL)](https://github.com/graphql/graphiql) and a React Frontend application using this endpoint. 54 | 55 | To run it, clone the repository, install dependencies and start a server: 56 | 57 | ``` 58 | git clone git@github.com:contentful-labs/cf-graphql.git 59 | cd cf-graphql/demo 60 | # optionally change your node version with nvm, anything 6+ should work just fine 61 | # we prefer node v6 matching the current AWS Lambda environment 62 | nvm use 63 | npm install 64 | npm start 65 | ``` 66 | 67 | Use to query the data from within your application and navigate to to use the IDE (GraphiQL) for test-querying. Please refer to the [Querying](#querying) section for more details. 68 | 69 | If you also want to see how to integrate GraphQL in a React technology stack the demo project also contains an application based on the [Apollo framework](https://www.apollodata.com/). To check it out use . 70 | 71 | To use your own Contentful space with the demo, you have to provide: 72 | 73 | - space ID 74 | - CDA token 75 | - CMA token 76 | 77 | Please refer the ["Authentication" section](https://www.contentful.com/developers/docs/references/authentication/) of Contentful's documentation. 78 | 79 | You can provide listed values with env variables: 80 | 81 | ``` 82 | SPACE_ID=some-space-id CDA_TOKEN=its-cda-token CMA_TOKEN=your-cma-token npm start 83 | ``` 84 | 85 | 86 | ### Deploy to [Zeit's `now`](https://zeit.co/now) 87 | 88 | To be able to deploy to [Zeit's `now`](https://zeit.co/now) you need to have an activated account. There is a free open source option available. 89 | 90 | You can also deploy the demo with `now`. In your terminal, navigate to the `demo/` directory and run: 91 | 92 | ``` 93 | npm run deploy-demo-now 94 | ``` 95 | 96 | As soon as the deployment is done you'll have a URL of your GraphQL server copied. 97 | 98 | You can also create a deployment for your own space: 99 | 100 | ``` 101 | SPACE_ID=some-space-id CDA_TOKEN=its-cda-token CMA_TOKEN=your-cma-token npm run deploy-now 102 | ``` 103 | 104 | Please note: 105 | 106 | - when deploying a server to consume Contentful's "Blog" space template, the command to use is `npm run deploy-demo-now`; when the demo should be configured to use your own space, the command is `npm run deploy-now` 107 | - if you've never used `now` before, you'll be asked to provide your e-mail; just follow on-screen instructions 108 | - if you use `now`'s OSS plan (the default one), the source code will be public; it's completely fine: all credentials are passed as env variables and are not available publicly 109 | 110 | 111 | ## Programmatic usage 112 | 113 | The library can be installed with `npm`: 114 | 115 | ``` 116 | npm install --save cf-graphql 117 | ``` 118 | 119 | Let's assume we've required this module with `const cfGraphql = require('cf-graphql')`. To create a schema out of your space you need to call `cfGraphgl.createSchema(spaceGraph)`. 120 | 121 | What is `spaceGraph`? It is a graph-like data structure containing descriptions of content types of your space which additionally provide some extra pieces of information allowing the library to create a GraphQL schema. 122 | 123 | To prepare this data structure you need to fetch raw content types data from the [CMA](https://www.contentful.com/developers/docs/references/content-management-api/). Let's create a Contentful client first: 124 | 125 | ```js 126 | const client = cfGraphql.createClient({ 127 | spaceId: 'some-space-id', 128 | cdaToken: 'its-cda-token', 129 | cmaToken: 'your-cma-token' 130 | }); 131 | ``` 132 | 133 | `spaceId`, `cdaToken` and `cmaToken` options are required. You can also pass the following options: 134 | 135 | - `locale` - a locale code to use when fetching content. If not provided, the default locale of a space is used 136 | - `preview` - if `true`, CPA will be used instead of CDA for fetching content 137 | - `cpaToken` - if `preview` is `true` then this option has to hold a CPA token 138 | 139 | Fetch content types with your `client` and then pass them to `cfGraphql.prepareSpaceGraph(rawCts)`: 140 | 141 | ```js 142 | client.getContentTypes() 143 | .then(cfGraphql.prepareSpaceGraph) 144 | .then(spaceGraph => { 145 | // `spaceGraph` can be passed to `cfGraphql.createSchema`! 146 | }); 147 | ``` 148 | 149 | The last step is to use the schema with a server. A popular choice is [express-graphql](https://github.com/graphql/express-graphql). The only caveat is how the context is constructed. The library expects the `entryLoader` key of the context to be set to an instance created with `client.createEntryLoader()`: 150 | 151 | ```js 152 | // Skipped in snippet: `require` calls, Express app setup, `client` creation. 153 | // `spaceGraph` was fetched and prepared in the previous snippet. In most cases 154 | // you shouldn't be doing it per request, once is fine. 155 | const schema = cfGraphql.createSchema(spaceGraph); 156 | 157 | // IMPORTANT: we're passing a function to `graphqlHTTP`: this function will be 158 | // called every time a GraphQL query arrives to create a fresh entry loader. 159 | // You can also use `expressGraphqlExtension` described below. 160 | app.use('/graphql', graphqlHTTP(function () { 161 | return { 162 | schema, 163 | context: {entryLoader: client.createEntryLoader()} 164 | }; 165 | })); 166 | ``` 167 | 168 | [You can see a fully-fledged example in the `demo/` directory](./demo/server.js). 169 | 170 | 171 | ## Querying 172 | 173 | For each Contentful content type three root-level fields are created: 174 | 175 | - a singular field accepts a required `id` argument and resolves to a single entity 176 | - a collection field accepts an optional `q`, `skip` and `limit` arguments and resolves to a list of entities 177 | - a collection metadata field accepts an optional `q` argument and resolves to a metadata object (currently comprising only `count`) 178 | 179 | 180 | Please note that: 181 | 182 | - the `q` argument is a query string you could use with the [CDA](https://www.contentful.com/developers/docs/references/content-delivery-api/) 183 | - both `skip` and `limit` arguments can be used to fetch desired page of results 184 | * `skip` defaults to `0` 185 | * `limit` defaults to `50` and cannot be greater than `1000` 186 | * some query string parameters cannot be used: 187 | * `skip`, `limit` - use collection field arguments instead 188 | * `include`, `content_type` - no need for them, the library will determine and use appropriate values internally 189 | * `locale` - all the content is fetched for a single locale. By default the default locale is used; alternate locale can be selected with the `locale` configuration option of `cfGraphql.createClient` 190 | 191 | Assuming you've got two content types named `post` and `author` with listed fields, this query is valid: 192 | 193 | ```graphql 194 | { 195 | authors { 196 | name 197 | } 198 | 199 | authors(skip: 10, limit: 10) { 200 | title 201 | rating 202 | } 203 | 204 | _authorsMeta { 205 | count 206 | } 207 | 208 | posts(q: "fields.rating[gt]=5") { 209 | title 210 | rating 211 | } 212 | 213 | _postsMeta(q: "fields.rating[gt]=5") { 214 | count 215 | } 216 | 217 | post(id: "some-post-id") { 218 | title 219 | author 220 | comments 221 | } 222 | } 223 | ``` 224 | 225 | Reference fields will be resolved to: 226 | 227 | - a specific type, if there is a validation that allows only entries of some specific content type to be linked 228 | - the `EntryType`, if there is no such constraint. The `EntryType` is an interface implemented by all the specific types 229 | 230 | Example where the `author` field links only entries of one content type and the `related` field links entries of multiple content types: 231 | 232 | ```graphql 233 | { 234 | posts { 235 | author { 236 | name 237 | website 238 | } 239 | 240 | related { 241 | ... on Tag { 242 | tagName 243 | } 244 | ... on Place { 245 | location 246 | name 247 | } 248 | } 249 | } 250 | } 251 | ``` 252 | 253 | Backreferences (_backrefs_) are automatically created for links. Assume our `post` content type links to the `author` content type via a field named `author`. Getting an author of a post is easy, getting a list of posts by an author is not. `_backrefs` mitigate this problem: 254 | 255 | ```graphql 256 | { 257 | authors { 258 | _backrefs { 259 | posts__via__author { 260 | title 261 | } 262 | } 263 | } 264 | } 265 | ``` 266 | 267 | When using backreferences, there is a couple of things to keep in mind: 268 | 269 | - backrefs may be slow; always test with a dataset which is comparable with what you've got in production 270 | - backrefs are generated only when a reference field specifies a single allowed link content type 271 | - `_backrefs` is prefixed with a single underscore 272 | - `__via__` is surrounded with two underscores; you can read this query out loud like this: _"get posts that link to author via the author field"_ 273 | 274 | 275 | ## Helpers 276 | 277 | `cf-graphql` comes with helpers that help you with the `cf-graphql` integration. These are used inside of [the demo application](https://github.com/contentful-labs/cf-graphql/tree/master/demo). 278 | 279 | 280 | ### `expressGraphqlExtension` 281 | 282 | `expressGraphqlExtension` is a simple utility producing a function that can be passed directly to the [`express-graphql` middleware](https://github.com/graphql/express-graphql). 283 | 284 | ```javascript 285 | // Skipped in this snippet: client and space graph creation 286 | const schema = cfGraphql.createSchema(spaceGraph); 287 | 288 | const opts = { 289 | // display the current cf-graphql version in responses 290 | version: true, 291 | // include list of the underlying Contentful CDA calls with their timing 292 | timeline: true, 293 | // display detailed error information 294 | detailedErrors: true 295 | }; 296 | 297 | const ext = cfGraphql.helpers.expressGraphqlExtension(client, schema, opts); 298 | app.use('/graphql', graphqlHTTP(ext)); 299 | ``` 300 | 301 | **Important**: Most likely don't want to enable `timeline` and `detailedErrors` in your production environment. 302 | 303 | 304 | ### `graphiql` 305 | 306 | If you want to run your own GraphiQL and don't want to rely on the one shipping with e.g. [express-graphql](https://github.com/graphql/express-graphql) then you could use the `graphiql` helper. 307 | 308 | ```javascript 309 | const ui = cfGraphql.helpers.graphiql({title: 'cf-graphql demo'}); 310 | app.get('/', (_, res) => res.set(ui.headers).status(ui.statusCode).end(ui.body)); 311 | ``` 312 | 313 | 314 | ## Contributing 315 | 316 | Issue reports and PRs are more than welcomed. 317 | 318 | 319 | ## License 320 | 321 | MIT 322 | -------------------------------------------------------------------------------- /demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "extends": ["eslint:recommended", "plugin:react/recommended"], 9 | "parserOptions": { 10 | "ecmaFeatures": { 11 | "experimentalObjectRestSpread": true, 12 | "jsx": true 13 | }, 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "react" 18 | ], 19 | "rules": { 20 | "indent": [ 21 | "error", 22 | 2, 23 | { "MemberExpression": 0 } 24 | ], 25 | "linebreak-style": [ 26 | "error", 27 | "unix" 28 | ], 29 | "quotes": [ 30 | "error", 31 | "single" 32 | ], 33 | "semi": [ 34 | "error", 35 | "always" 36 | ], 37 | "no-console": [ 38 | "off" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /demo/demo-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "spaceId": "d0nux3lw6z7l", 3 | "cdaToken": "3622a4162d173f335f465f286fd5aac21888ba66744a76a24708c0a4f98999ef", 4 | "spaceGraph": [ 5 | { 6 | "id": "5KMiN6YPvi42icqAUQMCQe", 7 | "names": { 8 | "field": "category", 9 | "collectionField": "categories", 10 | "type": "Category", 11 | "backrefsType": "CategoryBackrefs" 12 | }, 13 | "fields": [ 14 | { 15 | "id": "title", 16 | "type": "String" 17 | }, 18 | { 19 | "id": "icon", 20 | "type": "Link" 21 | } 22 | ], 23 | "backrefs": [ 24 | { 25 | "ctId": "2wKn6yEnZewu2SCCkus4as", 26 | "fieldId": "category", 27 | "backrefFieldName": "posts__via__category" 28 | } 29 | ] 30 | }, 31 | { 32 | "id": "1kUEViTN4EmGiEaaeC6ouY", 33 | "names": { 34 | "field": "author", 35 | "collectionField": "authors", 36 | "type": "Author", 37 | "backrefsType": "AuthorBackrefs" 38 | }, 39 | "fields": [ 40 | { 41 | "id": "name", 42 | "type": "String" 43 | }, 44 | { 45 | "id": "website", 46 | "type": "String" 47 | }, 48 | { 49 | "id": "profilePhoto", 50 | "type": "Link" 51 | }, 52 | { 53 | "id": "biography", 54 | "type": "String" 55 | } 56 | ], 57 | "backrefs": [ 58 | { 59 | "ctId": "2wKn6yEnZewu2SCCkus4as", 60 | "fieldId": "author", 61 | "backrefFieldName": "posts__via__author" 62 | } 63 | ] 64 | }, 65 | { 66 | "id": "2wKn6yEnZewu2SCCkus4as", 67 | "names": { 68 | "field": "post", 69 | "collectionField": "posts", 70 | "type": "Post", 71 | "backrefsType": "PostBackrefs" 72 | }, 73 | "fields": [ 74 | { 75 | "id": "title", 76 | "type": "String" 77 | }, 78 | { 79 | "id": "slug", 80 | "type": "String" 81 | }, 82 | { 83 | "id": "author", 84 | "type": "Array>", 85 | "linkedCt": "1kUEViTN4EmGiEaaeC6ouY" 86 | }, 87 | { 88 | "id": "body", 89 | "type": "String" 90 | }, 91 | { 92 | "id": "category", 93 | "type": "Array>", 94 | "linkedCt": "5KMiN6YPvi42icqAUQMCQe" 95 | }, 96 | { 97 | "id": "tags", 98 | "type": "Array" 99 | }, 100 | { 101 | "id": "featuredImage", 102 | "type": "Link" 103 | }, 104 | { 105 | "id": "date", 106 | "type": "String" 107 | }, 108 | { 109 | "id": "comments", 110 | "type": "Bool" 111 | } 112 | ] 113 | } 114 | ] 115 | } 116 | -------------------------------------------------------------------------------- /demo/demo-space-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful/cf-graphql/3e5c427a545654e9bd6c4585813fdc3fb451cee7/demo/demo-space-graph.png -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-graphql-demo", 3 | "description": "A demo project for cf-graphql", 4 | "private": true, 5 | "scripts": { 6 | "build": "webpack --progress", 7 | "start": "node server.js", 8 | "deploy-now": "now --public -e SPACE_ID -e CDA_TOKEN -e CMA_TOKEN", 9 | "deploy-demo-now": "now --public", 10 | "lint": "eslint '*.js' 'src/*.js'", 11 | "postinstall": "npm run build" 12 | }, 13 | "now": { 14 | "engines": { 15 | "node": "^6.11.3" 16 | } 17 | }, 18 | "babel": { 19 | "presets": [ 20 | "es2015", 21 | "react", 22 | "stage-2" 23 | ] 24 | }, 25 | "dependencies": { 26 | "cf-graphql": "^0.5.0", 27 | "cors": "^2.8.3", 28 | "express": "^4.15.3", 29 | "express-graphql": "^0.6.6", 30 | "prop-types": "^15.5.10", 31 | "react": "^15.6.1", 32 | "react-apollo": "^1.4.2", 33 | "react-dom": "^15.6.1" 34 | }, 35 | "devDependencies": { 36 | "babel-core": "^6.25.0", 37 | "babel-loader": "^7.0.0", 38 | "babel-preset-es2015": "^6.24.1", 39 | "babel-preset-react": "^6.24.1", 40 | "babel-preset-stage-2": "^6.24.1", 41 | "copy-webpack-plugin": "^4.0.1", 42 | "eslint": "^4.0.0", 43 | "eslint-plugin-react": "^7.1.0", 44 | "now": "^7.1.0", 45 | "webpack": "^2.6.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /demo/prepare-demo-data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This script prepares a space graph and saves it in the repository so the 4 | // example can be run w/o providing credentials 5 | // 6 | // The "demo-data.json" file should be commited. 7 | // 8 | // This script should be used by cf-graphql contributors only. If you want to 9 | // build on top of cf-graphql, please see the "server.js" file. 10 | 11 | const fs = require('fs'); 12 | const path = require('path'); 13 | 14 | const cfGraphql = require('..'); 15 | 16 | const spaceId = process.env.SPACE_ID; 17 | const cdaToken = process.env.CDA_TOKEN; 18 | const cmaToken = process.env.CMA_TOKEN; 19 | 20 | const client = cfGraphql.createClient({spaceId, cdaToken, cmaToken}); 21 | 22 | client.getContentTypes() 23 | .then(cfGraphql.prepareSpaceGraph) 24 | .then(spaceGraph => { 25 | const content = JSON.stringify({spaceId, cdaToken, spaceGraph}, null, 2); 26 | fs.writeFileSync(path.join(__dirname, 'demo-data.json'), content, 'utf8'); 27 | console.log('Demo data saved'); 28 | }) 29 | .catch(err => { 30 | console.log(err); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /demo/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const cfGraphql = require('cf-graphql'); 5 | const express = require('express'); 6 | const cors = require('cors'); 7 | const graphqlHTTP = require('express-graphql'); 8 | 9 | const port = process.env.PORT || 4000; 10 | const spaceId = process.env.SPACE_ID; 11 | const cdaToken = process.env.CDA_TOKEN; 12 | const cmaToken = process.env.CMA_TOKEN; 13 | 14 | if (spaceId && cdaToken && cmaToken) { 15 | console.log('Space ID, CDA token and CMA token provided'); 16 | console.log(`Fetching space (${spaceId}) content types to create a space graph`); 17 | useProvidedSpace(); 18 | } else { 19 | console.log('Using a demo space'); 20 | console.log('You can provide env vars (see README.md) to use your own space'); 21 | useDemoSpace(); 22 | } 23 | 24 | // this function implements a flow you could use in your application: 25 | // 1. fetch content types 26 | // 2. prepare a space graph 27 | // 3. create a schema out of the space graph 28 | // 4. run a server 29 | function useProvidedSpace () { 30 | const client = cfGraphql.createClient({spaceId, cdaToken, cmaToken}); 31 | 32 | client.getContentTypes() 33 | .then(cfGraphql.prepareSpaceGraph) 34 | .then(spaceGraph => { 35 | const names = spaceGraph.map(ct => ct.names.type).join(', '); 36 | console.log(`Contentful content types prepared: ${names}`); 37 | return spaceGraph; 38 | }) 39 | .then(cfGraphql.createSchema) 40 | .then(schema => startServer(client, schema)) 41 | .catch(fail); 42 | } 43 | 44 | // this function is being run if you don't provide credentials to your own space 45 | function useDemoSpace () { 46 | const {spaceId, cdaToken, spaceGraph} = require('./demo-data.json'); 47 | const client = cfGraphql.createClient({spaceId, cdaToken}); 48 | const schema = cfGraphql.createSchema(spaceGraph); 49 | startServer(client, schema); 50 | } 51 | 52 | function startServer (client, schema) { 53 | const app = express(); 54 | app.use(cors()); 55 | 56 | app.use('/client', express.static(path.join(__dirname, 'dist'))); 57 | 58 | const ui = cfGraphql.helpers.graphiql({title: 'cf-graphql demo'}); 59 | app.get('/', (_, res) => res.set(ui.headers).status(ui.statusCode).end(ui.body)); 60 | 61 | const opts = {version: true, timeline: true, detailedErrors: false}; 62 | const ext = cfGraphql.helpers.expressGraphqlExtension(client, schema, opts); 63 | app.use('/graphql', graphqlHTTP(ext)); 64 | 65 | app.listen(port); 66 | console.log('Running a GraphQL server!'); 67 | console.log(`You can access GraphiQL at localhost:${port}`); 68 | console.log(`You can use the GraphQL endpoint at localhost:${port}/graphql/`); 69 | console.log(`You can have a look at a React Frontend at localhost:${port}/client/`); 70 | } 71 | 72 | function fail (err) { 73 | console.log(err); 74 | process.exit(1); 75 | } 76 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | createNetworkInterface, 5 | ApolloClient, 6 | gql, 7 | graphql, 8 | ApolloProvider, 9 | } from 'react-apollo'; 10 | 11 | const client = new ApolloClient({ 12 | networkInterface: createNetworkInterface({ 13 | uri: '/graphql/' 14 | }) 15 | }); 16 | 17 | const graphQLQuery = gql` 18 | { 19 | authors { 20 | name 21 | } 22 | categories { 23 | title 24 | } 25 | posts { 26 | title 27 | } 28 | } 29 | `; 30 | 31 | const getGraphQLEnhancedComponent = graphql(graphQLQuery); 32 | 33 | const DataViewer = ({data: {loading, error, authors, categories, posts}}) => { 34 | if (loading) return

Loading ...

; 35 | if (error) return

{error.message}

; 36 | 37 | return ( 38 |
39 |

Authors

40 |
    {authors.map(a =>
  • {a.name}
  • )}
41 |

Categories

42 |
    {categories.map(c =>
  • {c.title}
  • )}
43 |

Posts

44 |
    {posts.map(p =>
  • {p.title}
  • )}
45 |
46 | ); 47 | }; 48 | 49 | DataViewer.propTypes = { 50 | data: PropTypes.object 51 | }; 52 | 53 | const DataViewerWithData = getGraphQLEnhancedComponent(DataViewer); 54 | 55 | class App extends Component { 56 | render() { 57 | return ( 58 | 59 |
60 | Contentful Logo 61 |

Using GraphQL with Contentful

62 |

This example shows you a GraphQL setup that relies on Contentful. 63 | If fetches all items for three different content types (author, category, post) 64 | which is normally only possible with three API calls.

65 |

With the following GraphQL query it can be done with a single call.

66 |

67 |             {graphQLQuery.loc.source.body}
68 |           
69 |

This demo uses React and the Apollo Framework.

70 |
71 | 72 |
73 |
74 | ); 75 | } 76 | } 77 | 78 | export default App; 79 | -------------------------------------------------------------------------------- /demo/src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful/cf-graphql/3e5c427a545654e9bd6c4585813fdc3fb451cee7/demo/src/favicon.png -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | img { 8 | display: block; 9 | margin: 1em auto 2em; 10 | } 11 | 12 | .wrapper { 13 | max-width: 50em; 14 | margin: 0 auto; 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React App backed by Contentful and GraphQL 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /demo/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contentful/cf-graphql/3e5c427a545654e9bd6c4585813fdc3fb451cee7/demo/src/logo.png -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const webpack = require('webpack'); 4 | const root = x => require('path').join(__dirname, x); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | 7 | module.exports = { 8 | entry: root('src/index.js'), 9 | module: { 10 | loaders: [ 11 | { 12 | test: /\.js$/, 13 | exclude: /node_modules/, 14 | loader: 'babel-loader' 15 | } 16 | ] 17 | }, 18 | output: { 19 | path: root('dist'), 20 | publicPath: '/', 21 | filename: 'bundle.js' 22 | }, 23 | plugins: [ 24 | new CopyWebpackPlugin([ 25 | { 26 | from: 'src/*.+(html|css|png)', 27 | flatten: true 28 | } 29 | ]), 30 | new webpack.DefinePlugin({ 31 | 'process.env': { 32 | NODE_ENV: JSON.stringify('production') 33 | } 34 | }), 35 | new webpack.optimize.UglifyJsPlugin() 36 | ] 37 | }; 38 | -------------------------------------------------------------------------------- /dev/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Dev server that uses the local module: 4 | const cfGraphql = require('..'); 5 | 6 | // Shouldn't be versioned, can contain a CMA token 7 | const config = require('./config.json'); 8 | 9 | const client = cfGraphql.createClient(config); 10 | 11 | const express = require('express'); 12 | const graphqlHTTP = require('express-graphql'); 13 | const app = express(); 14 | const port = config.port || 4000; 15 | 16 | client.getContentTypes() 17 | .then(cfGraphql.prepareSpaceGraph) 18 | .then(spaceGraph => { 19 | const names = spaceGraph.map(ct => ct.names.type).join(', '); 20 | console.log(`Contentful content types prepared: ${names}`); 21 | return spaceGraph; 22 | }) 23 | .then(cfGraphql.createSchema) 24 | .then(schema => { 25 | const ui = cfGraphql.helpers.graphiql({title: 'cf-graphql dev server'}); 26 | app.get('/', (_, res) => res.set(ui.headers).status(ui.statusCode).end(ui.body)); 27 | 28 | const opts = {version: true, timeline: true, detailedErrors: false}; 29 | const ext = cfGraphql.helpers.expressGraphqlExtension(client, schema, opts); 30 | app.use('/graphql', graphqlHTTP(ext)); 31 | 32 | app.listen(port); 33 | console.log(`Running a GraphQL server, listening on ${port}`); 34 | }) 35 | .catch(err => { 36 | console.log(err); 37 | process.exit(1); 38 | }); 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-graphql", 3 | "description": "Generate a GraphQL schema out of your Contentful space", 4 | "version": "0.5.0", 5 | "license": "MIT", 6 | "repository": "contentful-labs/cf-graphql", 7 | "contributors": [ 8 | { 9 | "name": "Jakub Elżbieciak", 10 | "email": "jelz@post.pl", 11 | "url": "https://elzbieciak.pl" 12 | }, 13 | { 14 | "name": "Michael Elsdörfer", 15 | "email": "michael@elsdoerfer.com", 16 | "url": "https://blog.elsdoerfer.name" 17 | }, 18 | { 19 | "name": "Frederik Lölhöffel", 20 | "email": "frederik@contentful.com", 21 | "url": "https://twitter.com/loewensprung" 22 | }, 23 | { 24 | "name": "Stefan Judis", 25 | "email": "stefanjudis@gmail.com", 26 | "url": " https://www.stefanjudis.de" 27 | }, 28 | { 29 | "name": "Pedro Valentim", 30 | "email": "pedro@vlt.im", 31 | "url": "https://github.com/pvalentim" 32 | } 33 | ], 34 | "keywords": [ 35 | "graphql", 36 | "contentful", 37 | "graph", 38 | "schema", 39 | "cda" 40 | ], 41 | "main": "src/index.js", 42 | "scripts": { 43 | "dev": "nodemon dev/server.js", 44 | "dump": "mkdirp dump && npm run dump-graph && npm run dump-schema", 45 | "dump-graph": "graphqlviz http://localhost:4000/graphql | dot -Tpng -o dump/graph.png", 46 | "dump-schema": "fetch-graphql-schema http://localhost:4000/graphql -r -o dump/schema.graphql", 47 | "lint": "eslint 'src/**/*.js' 'test/**/*.js' 'dev/**/*.js'", 48 | "test": "tape 'test/**/*.js'", 49 | "coverage": "istanbul cover tape 'test/**/*.js'", 50 | "codecov": "cat coverage/coverage.json | codecov" 51 | }, 52 | "dependencies": { 53 | "dataloader": "^1.3.0", 54 | "graphql": "^0.11.7", 55 | "lodash.camelcase": "^4.3.0", 56 | "lodash.chunk": "^4.2.0", 57 | "lodash.get": "^4.4.2", 58 | "lodash.upperfirst": "^4.3.1", 59 | "node-fetch": "^1.7.3", 60 | "pluralize": "^7.0.0" 61 | }, 62 | "devDependencies": { 63 | "codecov": "^2.3.0", 64 | "eslint": "^4.9.0", 65 | "express": "^4.15.4", 66 | "express-graphql": "^0.6.7", 67 | "fetch-graphql-schema": "^0.2.1", 68 | "graphqlviz": "^2.0.1", 69 | "istanbul": "^0.4.5", 70 | "just-extend": "1.1.22", 71 | "mkdirp": "^0.5.1", 72 | "nodemon": "^1.12.1", 73 | "proxyquire": "^1.8.0", 74 | "sinon": "^4.0.1", 75 | "tape": "^4.8.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/backref-types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash.get'); 4 | const {GraphQLObjectType, GraphQLList} = require('graphql'); 5 | 6 | module.exports = createBackrefsType; 7 | 8 | function createBackrefsType (ct, ctIdToType) { 9 | const fields = prepareBackrefsFields(ct, ctIdToType); 10 | if (Object.keys(fields).length > 0) { 11 | return new GraphQLObjectType({name: ct.names.backrefsType, fields}); 12 | } 13 | } 14 | 15 | function prepareBackrefsFields (ct, ctIdToType) { 16 | return (ct.backrefs || []).reduce((acc, backref) => { 17 | const Type = ctIdToType[backref.ctId]; 18 | if (Type) { 19 | acc[backref.backrefFieldName] = createBackrefFieldConfig(backref, Type); 20 | } 21 | return acc; 22 | }, {}); 23 | } 24 | 25 | function createBackrefFieldConfig (backref, Type) { 26 | return { 27 | type: new GraphQLList(Type), 28 | resolve: (entryId, _, ctx) => { 29 | return ctx.entryLoader.queryAll(backref.ctId) 30 | .then(entries => filterEntries(entries, backref.fieldId, entryId)); 31 | } 32 | }; 33 | } 34 | 35 | function filterEntries (entries, refFieldId, entryId) { 36 | return entries.filter(entry => { 37 | const refField = _get(entry, ['fields', refFieldId]); 38 | 39 | if (Array.isArray(refField)) { 40 | return !!refField.find(link => _get(link, ['sys', 'id']) === entryId); 41 | } else if (typeof refField === 'object') { 42 | return _get(refField, ['sys', 'id']) === entryId; 43 | } else { 44 | return false; 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/base-types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash.get'); 4 | 5 | const { 6 | GraphQLNonNull, 7 | GraphQLString, 8 | GraphQLObjectType, 9 | GraphQLID, 10 | GraphQLInterfaceType, 11 | GraphQLFloat, 12 | GraphQLInt 13 | } = require('graphql'); 14 | 15 | const IDType = new GraphQLNonNull(GraphQLID); 16 | const NonNullStringType = new GraphQLNonNull(GraphQLString); 17 | 18 | const baseSysFields = { 19 | id: {type: IDType}, 20 | createdAt: {type: NonNullStringType}, 21 | updatedAt: {type: NonNullStringType} 22 | }; 23 | 24 | const entrySysFields = { 25 | contentTypeId: { 26 | type: IDType, 27 | resolve: sys => _get(sys, ['contentType', 'sys', 'id']) 28 | } 29 | }; 30 | 31 | const SysType = new GraphQLInterfaceType({ 32 | name: 'Sys', 33 | fields: baseSysFields 34 | }); 35 | 36 | const AssetSysType = createSysType('Asset'); 37 | const EntrySysType = createSysType('Entry', entrySysFields); 38 | 39 | const AssetType = new GraphQLObjectType({ 40 | name: 'Asset', 41 | fields: { 42 | sys: {type: AssetSysType}, 43 | title: { 44 | type: GraphQLString, 45 | resolve: asset => _get(asset, ['fields', 'title']) 46 | }, 47 | description: { 48 | type: GraphQLString, 49 | resolve: asset => _get(asset, ['fields', 'description']) 50 | }, 51 | url: { 52 | type: GraphQLString, 53 | resolve: asset => _get(asset, ['fields', 'file', 'url']) 54 | } 55 | } 56 | }); 57 | 58 | const EntryType = new GraphQLInterfaceType({ 59 | name: 'Entry', 60 | fields: {sys: {type: EntrySysType}} 61 | }); 62 | 63 | const LocationType = new GraphQLObjectType({ 64 | name: 'Location', 65 | fields: { 66 | lon: {type: GraphQLFloat}, 67 | lat: {type: GraphQLFloat} 68 | } 69 | }); 70 | 71 | const CollectionMetaType = new GraphQLObjectType({ 72 | name: 'CollectionMeta', 73 | fields: {count: {type: GraphQLInt}} 74 | }); 75 | 76 | module.exports = { 77 | IDType, 78 | SysType, 79 | AssetSysType, 80 | EntrySysType, 81 | AssetType, 82 | EntryType, 83 | LocationType, 84 | CollectionMetaType 85 | }; 86 | 87 | function createSysType (entityType, extraFields) { 88 | return new GraphQLNonNull(new GraphQLObjectType({ 89 | name: `${entityType}Sys`, 90 | interfaces: [SysType], 91 | fields: Object.assign({}, baseSysFields, extraFields || {}), 92 | isTypeOf: sys => _get(sys, ['type']) === entityType 93 | })); 94 | } 95 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createHttpClient = require('./http-client.js'); 4 | const createEntryLoader = require('./entry-loader.js'); 5 | 6 | const CDA = 'cdn'; 7 | const CPA = 'preview'; 8 | const CMA = 'api'; 9 | 10 | module.exports = createClient; 11 | 12 | function createClient (config) { 13 | return { 14 | getContentTypes: function () { 15 | return createContentfulClient(CMA, config) 16 | .get('/content_types', {limit: 1000}) 17 | .then(res => res.items); 18 | }, 19 | createEntryLoader: function () { 20 | const api = config.preview ? CPA : CDA; 21 | return createEntryLoader(createContentfulClient(api, config)); 22 | } 23 | }; 24 | } 25 | 26 | function createContentfulClient (api, config) { 27 | const protocol = config.secure !== false ? 'https' : 'http'; 28 | const domain = config.domain || 'contentful.com'; 29 | 30 | const token = { 31 | [CDA]: config.cdaToken, 32 | [CPA]: config.cpaToken, 33 | [CMA]: config.cmaToken 34 | }[api]; 35 | 36 | const defaultParams = {}; 37 | if ([CDA, CPA].includes(api) && config.locale) { 38 | defaultParams.locale = config.locale; 39 | } 40 | 41 | return createHttpClient({ 42 | base: `${protocol}://${api}.${domain}/spaces/${config.spaceId}`, 43 | headers: {Authorization: `Bearer ${token}`}, 44 | defaultParams 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/entry-loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash.get'); 4 | const chunk = require('lodash.chunk'); 5 | const qs = require('querystring'); 6 | const DataLoader = require('dataloader'); 7 | 8 | const INCLUDE_DEPTH = 1; 9 | const CHUNK_SIZE = 100; 10 | const DEFAULT_LIMIT = 50; 11 | const MAX_LIMIT = 1000; 12 | const FORBIDDEN_QUERY_PARAMS = ['skip', 'limit', 'include', 'content_type', 'locale']; 13 | 14 | module.exports = createEntryLoader; 15 | 16 | function createEntryLoader (http) { 17 | const loader = new DataLoader(load); 18 | const assets = {}; 19 | 20 | return { 21 | get: getOne, 22 | getMany: loader.loadMany.bind(loader), 23 | query: (ctId, args) => query(ctId, args).then(res => res.items), 24 | count: (ctId, args) => query(ctId, args).then(res => res.total), 25 | queryAll, 26 | getIncludedAsset: id => assets[id], 27 | getTimeline: () => http.timeline 28 | }; 29 | 30 | function load (ids) { 31 | // we need to chunk IDs and fire multiple requests so we don't produce URLs 32 | // that are too long (for the server to handle) 33 | const requests = chunk(ids, CHUNK_SIZE) 34 | .map(ids => http.get('/entries', { 35 | limit: CHUNK_SIZE, 36 | skip: 0, 37 | include: INCLUDE_DEPTH, 38 | 'sys.id[in]': ids.join(',') 39 | })); 40 | 41 | return Promise.all(requests) 42 | .then(responses => responses.reduce((acc, res) => { 43 | prime(res); 44 | _get(res, ['items'], []).forEach(e => acc[e.sys.id] = e); 45 | return acc; 46 | }, {})) 47 | .then(byId => ids.map(id => byId[id])); 48 | } 49 | 50 | function getOne (id, forcedCtId) { 51 | return loader.load(id) 52 | .then(res => { 53 | const ctId = _get(res, ['sys', 'contentType', 'sys', 'id']); 54 | if (forcedCtId && ctId !== forcedCtId) { 55 | throw new Error('Does not match the forced Content Type ID.'); 56 | } else { 57 | return res; 58 | } 59 | }); 60 | } 61 | 62 | function query (ctId, {q = '', skip = 0, limit = DEFAULT_LIMIT} = {}) { 63 | const parsed = qs.parse(q); 64 | Object.keys(parsed).forEach(key => { 65 | if (FORBIDDEN_QUERY_PARAMS.includes(key)) { 66 | throw new Error(`Cannot use a query param named "${key}" here.`); 67 | } 68 | }); 69 | 70 | const params = Object.assign({ 71 | limit, 72 | skip, 73 | include: INCLUDE_DEPTH, 74 | content_type: ctId 75 | }, parsed); 76 | 77 | return http.get('/entries', params).then(prime); 78 | } 79 | 80 | function queryAll (ctId) { 81 | const paramsFor = page => ({ 82 | limit: MAX_LIMIT, 83 | skip: page*MAX_LIMIT, 84 | include: INCLUDE_DEPTH, 85 | content_type: ctId 86 | }); 87 | 88 | return http.get('/entries', paramsFor(0)) 89 | .then(firstResponse => { 90 | const length = Math.ceil(firstResponse.total/MAX_LIMIT)-1; 91 | const pages = Array.apply(null, {length}).map((_, i) => i+1); 92 | const requests = pages.map(page => http.get('/entries', paramsFor(page))); 93 | return Promise.all([Promise.resolve(firstResponse)].concat(requests)); 94 | }) 95 | .then(responses => responses.reduce((acc, res) => { 96 | prime(res); 97 | return res.items.reduce((acc, item) => { 98 | if (!acc.some(e => e.sys.id === item.sys.id)) { 99 | return acc.concat([item]); 100 | } else { 101 | return acc; 102 | } 103 | }, acc); 104 | }, [])); 105 | } 106 | 107 | function prime (res) { 108 | _get(res, ['items'], []) 109 | .concat(_get(res, ['includes', 'Entry'], [])) 110 | .forEach(e => loader.prime(e.sys.id, e)); 111 | 112 | _get(res, ['includes', 'Asset'], []) 113 | .forEach(a => assets[a.sys.id] = a); 114 | 115 | return res; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/field-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash.get'); 4 | 5 | const { 6 | GraphQLString, 7 | GraphQLInt, 8 | GraphQLFloat, 9 | GraphQLBoolean, 10 | GraphQLList 11 | } = require('graphql'); 12 | 13 | const {AssetType, EntryType, LocationType} = require('./base-types.js'); 14 | 15 | const NOTHING = {}; 16 | 17 | const is = type => entity => typeof entity === type; 18 | const isString = is('string'); 19 | const isObject = is('object'); 20 | 21 | module.exports = { 22 | String: field => createFieldConfig(GraphQLString, field), 23 | Int: field => createFieldConfig(GraphQLInt, field), 24 | Float: field => createFieldConfig(GraphQLFloat, field), 25 | Bool: field => createFieldConfig(GraphQLBoolean, field), 26 | Location: field => createFieldConfig(LocationType, field), 27 | Object: createObjectFieldConfig, 28 | 'Array': createArrayOfStringsFieldConfig, 29 | 'Link': createAssetFieldConfig, 30 | 'Array>': createArrayOfAssetsFieldConfig, 31 | 'Link': createEntryFieldConfig, 32 | 'Array>': createArrayOfEntriesFieldConfig 33 | }; 34 | 35 | function createFieldConfig (Type, field, resolveFn) { 36 | return { 37 | type: Type, 38 | resolve: (entity, _, ctx) => { 39 | const fieldValue = _get(entity, ['fields', field.id], NOTHING); 40 | if (fieldValue !== NOTHING) { 41 | return resolveFn ? resolveFn(fieldValue, ctx) : fieldValue; 42 | } 43 | } 44 | }; 45 | } 46 | 47 | function createObjectFieldConfig (field) { 48 | return createFieldConfig(GraphQLString, field, val => JSON.stringify(val)); 49 | } 50 | 51 | function createArrayOfStringsFieldConfig (field) { 52 | return createFieldConfig(new GraphQLList(GraphQLString), field); 53 | } 54 | 55 | function createAssetFieldConfig (field) { 56 | return createFieldConfig(AssetType, field, getAsset); 57 | } 58 | 59 | function createArrayOfAssetsFieldConfig (field) { 60 | return createFieldConfig(new GraphQLList(AssetType), field, (links, ctx) => { 61 | if (Array.isArray(links)) { 62 | return links.map(link => getAsset(link, ctx)).filter(isObject); 63 | } 64 | }); 65 | } 66 | 67 | function getAsset (link, ctx) { 68 | const linkedId = getLinkedId(link); 69 | if (isString(linkedId)) { 70 | return ctx.entryLoader.getIncludedAsset(linkedId); 71 | } 72 | } 73 | 74 | function createEntryFieldConfig (field, ctIdToType) { 75 | return createFieldConfig(typeFor(field, ctIdToType), field, (link, ctx) => { 76 | const linkedId = getLinkedId(link); 77 | if (isString(linkedId)) { 78 | return ctx.entryLoader.get(linkedId, field.linkedCt); 79 | } 80 | }); 81 | } 82 | 83 | function createArrayOfEntriesFieldConfig (field, ctIdToType) { 84 | const Type = new GraphQLList(typeFor(field, ctIdToType)); 85 | 86 | return createFieldConfig(Type, field, (links, ctx) => { 87 | if (Array.isArray(links)) { 88 | const ids = links.map(getLinkedId).filter(isString); 89 | return ctx.entryLoader.getMany(ids).then(coll => coll.filter(isObject)); 90 | } 91 | }); 92 | } 93 | 94 | function getLinkedId (link) { 95 | return _get(link, ['sys', 'id']); 96 | } 97 | 98 | function typeFor ({linkedCt}, ctIdToType = {}) { 99 | if (linkedCt) { 100 | return ctIdToType[linkedCt] || EntryType; 101 | } else { 102 | return EntryType; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/helpers/express-graphql-extension.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // result of a `createExtension` call can be passed to express-graphql 4 | // 5 | // options available (all default to false): 6 | // - timeline: includes timing of HTTP calls 7 | // - detailedErrors: includes stacks of logged exceptions 8 | // - version: includes cf-graphql's version 9 | // 10 | // timeline extension and detailed errors are nice for development, but most 11 | // likely you want to skip them in your production setup 12 | 13 | module.exports = createExtension; 14 | 15 | function createExtension (client, schema, options = {}) { 16 | return function () { 17 | const start = Date.now(); 18 | const entryLoader = client.createEntryLoader(); 19 | return { 20 | context: {entryLoader}, 21 | schema, 22 | graphiql: false, 23 | extensions: prepareExtensions(start, options, entryLoader), 24 | formatError: options.detailedErrors ? formatError : undefined 25 | }; 26 | }; 27 | } 28 | 29 | function prepareExtensions (start, options, entryLoader) { 30 | if (!options.version && !options.timeline) { 31 | return; 32 | } 33 | 34 | return () => { 35 | const extensions = []; 36 | 37 | if (options.version) { 38 | extensions.push({ 39 | 'cf-graphql': {version: require('../../package.json').version} 40 | }); 41 | } 42 | 43 | if (options.timeline) { 44 | extensions.push({ 45 | time: Date.now()-start, 46 | timeline: entryLoader.getTimeline().map(httpCall => { 47 | return Object.assign({}, httpCall, {start: httpCall.start-start}); 48 | }) 49 | }); 50 | } 51 | 52 | return Object.assign({}, ...extensions); 53 | }; 54 | } 55 | 56 | function formatError (err) { 57 | return { 58 | message: err.message, 59 | locations: err.locations, 60 | stack: err.stack, 61 | path: err.path 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/helpers/graphiql.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = getResponse; 4 | 5 | function getResponse (opts) { 6 | return { 7 | statusCode: 200, 8 | headers: { 9 | 'Content-Type': 'text/html; charset=utf-8', 10 | 'Cache-Control': 'no-cache' 11 | }, 12 | body: body(opts) 13 | }; 14 | } 15 | 16 | function body (opts = {}) { 17 | const title = opts.title || 'GraphiQL'; 18 | const url = opts.url || '/graphql'; 19 | 20 | return ` 21 | 22 | 23 | 24 | ${title} 25 | 26 | 27 | 31 | 32 | 33 | 34 | 35 | 36 |
Loading...
37 | 62 | 63 | 64 | `; 65 | } 66 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | graphiql: require('./graphiql.js'), 5 | expressGraphqlExtension: require('./express-graphql-extension.js') 6 | }; 7 | -------------------------------------------------------------------------------- /src/http-client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | const qs = require('querystring'); 5 | const fetch = require('node-fetch'); 6 | const _get = require('lodash.get'); 7 | 8 | module.exports = createClient; 9 | 10 | function createClient (config) { 11 | config = config || {}; 12 | const opts = { 13 | base: config.base || '', 14 | headers: config.headers || {}, 15 | defaultParams: config.defaultParams || {}, 16 | timeline: config.timeline || [], 17 | cache: config.cache || {} 18 | }; 19 | 20 | return { 21 | get: (url, params = {}) => get(url, params, opts), 22 | timeline: opts.timeline 23 | }; 24 | } 25 | 26 | function get (url, params, opts) { 27 | const paramsWithDefaults = Object.assign({}, opts.defaultParams, params); 28 | const sortedQS = getSortedQS(paramsWithDefaults); 29 | if (typeof sortedQS === 'string' && sortedQS.length > 0) { 30 | url = `${url}?${sortedQS}`; 31 | } 32 | 33 | const {base, headers, timeline, cache} = opts; 34 | const cached = cache[url]; 35 | if (cached) { 36 | return cached; 37 | } 38 | 39 | const httpCall = {url, start: Date.now()}; 40 | timeline.push(httpCall); 41 | 42 | cache[url] = fetch( 43 | base + url, 44 | {headers: Object.assign({}, getUserAgent(), headers)} 45 | ) 46 | .then(checkStatus) 47 | .then(res => { 48 | httpCall.duration = Date.now()-httpCall.start; 49 | return res.json(); 50 | }); 51 | 52 | return cache[url]; 53 | } 54 | 55 | function checkStatus (res) { 56 | if (res.status >= 200 && res.status < 300) { 57 | return res; 58 | } else { 59 | const err = new Error(res.statusText); 60 | err.response = res; 61 | throw err; 62 | } 63 | } 64 | 65 | function getSortedQS (params) { 66 | return Object.keys(params).sort().reduce((acc, key) => { 67 | const pair = {}; 68 | pair[key] = params[key]; 69 | return acc.concat([qs.stringify(pair)]); 70 | }, []).join('&'); 71 | } 72 | 73 | function getUserAgent () { 74 | const segments = ['app contentful.cf-graphql', getOs(), getPlatform()]; 75 | const joined = segments.filter(s => typeof s === 'string').join('; '); 76 | return {'X-Contentful-User-Agent': `${joined};`}; 77 | } 78 | 79 | function getOs () { 80 | const name = { 81 | win32: 'Windows', 82 | darwin: 'macOS' 83 | }[os.platform()] || 'Linux'; 84 | 85 | const release = os.release(); 86 | if (release) { 87 | return `os ${name}/${release}`; 88 | } 89 | } 90 | 91 | function getPlatform () { 92 | const version = _get(process, ['versions', 'node']); 93 | if (version) { 94 | return `platform node.js/${version}`; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | createSchema, 5 | createQueryType, 6 | createQueryFields 7 | } = require('./schema.js'); 8 | 9 | module.exports = { 10 | createClient: require('./client.js'), 11 | prepareSpaceGraph: require('./prepare-space-graph.js'), 12 | createSchema, 13 | createQueryType, 14 | createQueryFields, 15 | helpers: require('./helpers') 16 | }; 17 | -------------------------------------------------------------------------------- /src/prepare-space-graph.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash.get'); 4 | const upperFirst = require('lodash.upperfirst'); 5 | const camelCase = require('lodash.camelcase'); 6 | const pluralize = require('pluralize'); 7 | 8 | const ENTITY_TYPES = [ 9 | 'Entry', 10 | 'Asset' 11 | ]; 12 | 13 | const SHORTCUT_FIELD_TYPE_MAPPING = { 14 | Entry: 'Link', 15 | Asset: 'Link', 16 | Symbols: 'Array', 17 | Entries: 'Array>', 18 | Assets: 'Array>' 19 | }; 20 | 21 | const SIMPLE_FIELD_TYPE_MAPPING = { 22 | Symbol: 'String', 23 | Text: 'String', 24 | Number: 'Float', 25 | Integer: 'Int', 26 | Date: 'String', 27 | Boolean: 'Bool', 28 | Location: 'Location', 29 | Object: 'Object' 30 | }; 31 | 32 | module.exports = prepareSpaceGraph; 33 | 34 | function prepareSpaceGraph (cts) { 35 | return addBackrefs(createSpaceGraph(cts)); 36 | } 37 | 38 | function createSpaceGraph (cts) { 39 | const accumulatedNames = {}; 40 | 41 | return cts.map(ct => ({ 42 | id: ct.sys.id, 43 | names: names(ct.name, accumulatedNames), 44 | fields: ct.fields.reduce((acc, f) => { 45 | return f.omitted ? acc : acc.concat([field(f)]); 46 | }, []) 47 | })); 48 | } 49 | 50 | function names (name, accumulatedNames) { 51 | const fieldName = camelCase(name); 52 | const typeName = upperFirst(fieldName); 53 | 54 | return checkForConflicts({ 55 | field: fieldName, 56 | collectionField: pluralize(fieldName), 57 | type: typeName, 58 | backrefsType: `${typeName}Backrefs` 59 | }, accumulatedNames); 60 | } 61 | 62 | function checkForConflicts (names, accumulatedNames) { 63 | Object.keys(names).forEach(key => { 64 | const value = names[key]; 65 | accumulatedNames[key] = accumulatedNames[key] || []; 66 | if (accumulatedNames[key].includes(value)) { 67 | throw new Error(`Conflicing name: "${value}". Type of name: "${key}"`); 68 | } 69 | accumulatedNames[key].push(value); 70 | }); 71 | 72 | return names; 73 | } 74 | 75 | function field (f) { 76 | ['sys', '_backrefs'].forEach(id => { 77 | if (f.id === id) { 78 | throw new Error(`Fields named "${id}" are unsupported`); 79 | } 80 | }); 81 | 82 | return { 83 | id: f.id, 84 | type: type(f), 85 | linkedCt: linkedCt(f) 86 | }; 87 | } 88 | 89 | function type (f) { 90 | if (f.type === 'Array') { 91 | if (f.items.type === 'Symbol') { 92 | return 'Array'; 93 | } else if (f.items.type === 'Link' && isEntityType(f.items.linkType)) { 94 | return `Array>`; 95 | } else { 96 | throw new Error('Invalid field of a type "Array"'); 97 | } 98 | } 99 | 100 | if (f.type === 'Link') { 101 | if (isEntityType(f.linkType)) { 102 | return `Link<${f.linkType}>`; 103 | } else { 104 | throw new Error('Invalid field of a type "Link"'); 105 | } 106 | } 107 | 108 | const mapped = SHORTCUT_FIELD_TYPE_MAPPING[f.type] || SIMPLE_FIELD_TYPE_MAPPING[f.type]; 109 | if (mapped) { 110 | return mapped; 111 | } else { 112 | throw new Error(`Unknown field type: "${f.type}"`); 113 | } 114 | } 115 | 116 | function isEntityType (x) { 117 | return ENTITY_TYPES.indexOf(x) > -1; 118 | } 119 | 120 | function linkedCt (f) { 121 | const prop = 'linkContentType'; 122 | const validation = getValidations(f).find(v => { 123 | return Array.isArray(v[prop]) && v[prop].length === 1; 124 | }); 125 | const linkedCt = validation && validation[prop][0]; 126 | 127 | if (linkedCt) { 128 | return linkedCt; 129 | } 130 | } 131 | 132 | function getValidations (f) { 133 | if (f.type === 'Array') { 134 | return _get(f, ['items', 'validations'], []); 135 | } else { 136 | return _get(f, ['validations'], []); 137 | } 138 | } 139 | 140 | function addBackrefs (spaceGraph) { 141 | const byId = spaceGraph.reduce((acc, ct) => { 142 | acc[ct.id] = ct; 143 | return acc; 144 | }, {}); 145 | 146 | spaceGraph.forEach(ct => ct.fields.forEach(field => { 147 | if (field.linkedCt && byId[field.linkedCt]) { 148 | const linked = byId[field.linkedCt]; 149 | linked.backrefs = linked.backrefs || []; 150 | linked.backrefs.push({ 151 | ctId: ct.id, 152 | fieldId: field.id, 153 | backrefFieldName: `${ct.names.collectionField}__via__${field.id}` 154 | }); 155 | } 156 | })); 157 | 158 | return spaceGraph; 159 | } 160 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash.get'); 4 | 5 | const { 6 | GraphQLSchema, 7 | GraphQLObjectType, 8 | GraphQLList, 9 | GraphQLString, 10 | GraphQLInt 11 | } = require('graphql'); 12 | 13 | const {EntrySysType, EntryType, IDType, CollectionMetaType} = require('./base-types.js'); 14 | const typeFieldConfigMap = require('./field-config.js'); 15 | const createBackrefsType = require('./backref-types.js'); 16 | 17 | module.exports = { 18 | createSchema, 19 | createQueryType, 20 | createQueryFields 21 | }; 22 | 23 | function createSchema (spaceGraph, queryTypeName) { 24 | return new GraphQLSchema({ 25 | query: createQueryType(spaceGraph, queryTypeName) 26 | }); 27 | } 28 | 29 | function createQueryType (spaceGraph, name = 'Query') { 30 | return new GraphQLObjectType({ 31 | name, 32 | fields: createQueryFields(spaceGraph) 33 | }); 34 | } 35 | 36 | function createQueryFields (spaceGraph) { 37 | const ctIdToType = {}; 38 | 39 | return spaceGraph.reduce((acc, ct) => { 40 | const defaultFieldsThunk = () => { 41 | const fields = {sys: {type: EntrySysType}}; 42 | const BackrefsType = createBackrefsType(ct, ctIdToType); 43 | if (BackrefsType) { 44 | fields._backrefs = {type: BackrefsType, resolve: e => e.sys.id}; 45 | } 46 | return fields; 47 | }; 48 | 49 | const fieldsThunk = () => ct.fields.reduce((acc, f) => { 50 | acc[f.id] = typeFieldConfigMap[f.type](f, ctIdToType); 51 | return acc; 52 | }, defaultFieldsThunk()); 53 | 54 | const Type = ctIdToType[ct.id] = new GraphQLObjectType({ 55 | name: ct.names.type, 56 | interfaces: [EntryType], 57 | fields: fieldsThunk, 58 | isTypeOf: entry => { 59 | const ctId = _get(entry, ['sys', 'contentType', 'sys', 'id']); 60 | return ctId === ct.id; 61 | } 62 | }); 63 | 64 | acc[ct.names.field] = { 65 | type: Type, 66 | args: {id: {type: IDType}}, 67 | resolve: (_, args, ctx) => ctx.entryLoader.get(args.id, ct.id) 68 | }; 69 | 70 | acc[ct.names.collectionField] = { 71 | type: new GraphQLList(Type), 72 | args: { 73 | q: {type: GraphQLString}, 74 | skip: {type: GraphQLInt}, 75 | limit: {type: GraphQLInt} 76 | }, 77 | resolve: (_, args, ctx) => ctx.entryLoader.query(ct.id, args) 78 | }; 79 | 80 | acc[`_${ct.names.collectionField}Meta`] = { 81 | type: CollectionMetaType, 82 | args: {q: {type: GraphQLString}}, 83 | resolve: (_, args, ctx) => ctx.entryLoader.count(ct.id, args).then(count => ({count})) 84 | }; 85 | 86 | return acc; 87 | }, {}); 88 | } 89 | -------------------------------------------------------------------------------- /test/backref-types.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const { 6 | graphql, 7 | GraphQLSchema, 8 | GraphQLObjectType, 9 | GraphQLString 10 | } = require('graphql'); 11 | 12 | const createBackrefsType = require('../src/backref-types.js'); 13 | 14 | test('backref-types: no or invalid backrefs given', function (t) { 15 | t.equal(createBackrefsType({}, {}), undefined); 16 | t.equal(createBackrefsType({backrefs: [{ctId: 'x'}, {ctId: 'y'}]}, {}), undefined); 17 | t.end(); 18 | }); 19 | 20 | test('backref-types: creating backrefers type', function (t) { 21 | t.plan(3); 22 | 23 | const PostType = new GraphQLObjectType({ 24 | name: 'Post', 25 | fields: { 26 | title: {type: GraphQLString, resolve: e => e.fields.title} 27 | } 28 | }); 29 | 30 | const graphCt = { 31 | names: { 32 | type: 'Category', 33 | backrefsType: 'CategoryBackrefs', 34 | }, 35 | backrefs: [ 36 | { 37 | ctId: 'pct', 38 | fieldId: 'category', 39 | backrefFieldName: 'posts__via__category' 40 | }, 41 | { 42 | ctId: 'pct', 43 | fieldId: 'category2', 44 | backrefFieldName: 'posts__via__category2' 45 | }, 46 | { 47 | ctId: 'missing' 48 | } 49 | ] 50 | }; 51 | 52 | const BackrefsType = createBackrefsType(graphCt, {pct: PostType}); 53 | 54 | const schema = new GraphQLSchema({ 55 | query: new GraphQLObjectType({ 56 | name: 'Query', 57 | fields: {test: {type: BackrefsType, resolve: () => 'someid'}} 58 | }) 59 | }); 60 | 61 | const posts = [ 62 | { 63 | sys: {id: 'p1'}, 64 | fields: { 65 | title: 'p1t', 66 | category: {sys: {id: 'someid'}}, 67 | } 68 | }, 69 | { 70 | sys: {id: 'p2'}, 71 | fields: { 72 | title: 'p2t', 73 | category2: [{sys: {id: 'xxx'}}, {sys: {id: 'yyy'}}] 74 | } 75 | }, 76 | { 77 | sys: {id: 'p3'}, 78 | fields: { 79 | title: 'p3t', 80 | category: {sys: {id: 'xxx'}}, 81 | category2: [{sys: {id: 'yyy'}}, {sys: {id: 'someid'}}] 82 | } 83 | } 84 | ]; 85 | 86 | const ctx = {entryLoader: {queryAll: () => Promise.resolve(posts)}}; 87 | 88 | graphql( 89 | schema, 90 | '{ test { posts__via__category { title } posts__via__category2 { title } } }', 91 | null, 92 | ctx 93 | ).then(res => { 94 | t.deepEqual(res.data.test.posts__via__category, [{title: 'p1t'}]); 95 | t.deepEqual(res.data.test.posts__via__category2, [{title: 'p3t'}]); 96 | t.equal(res.errors, undefined); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/base-types.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const {graphql, GraphQLSchema, GraphQLObjectType} = require('graphql'); 5 | 6 | const {AssetType, EntryType, EntrySysType, LocationType} = require('../src/base-types.js'); 7 | 8 | test('base-types: asset', function (t) { 9 | const createSchema = val => new GraphQLSchema({ 10 | query: new GraphQLObjectType({ 11 | name: 'Query', 12 | fields: {test: {type: AssetType, resolve: () => val}} 13 | }) 14 | }); 15 | 16 | graphql(createSchema(null), '{ test { title } }').then(res => { 17 | t.equal(res.data.test, null); 18 | t.equal(res.errors, undefined); 19 | }); 20 | 21 | graphql( 22 | createSchema({sys: {type: 'Asset', id: 'aid'}}), 23 | '{ test { sys { id } title description } }' 24 | ).then(res => { 25 | t.deepEqual(res.data.test, { 26 | sys: {id: 'aid'}, 27 | title: null, 28 | description: null 29 | }); 30 | t.equal(res.errors, undefined); 31 | }); 32 | 33 | graphql( 34 | createSchema({fields: {title: 'boo'}}), 35 | '{ test { title description } }' 36 | ).then(res => { 37 | t.deepEqual(res.data.test, {title: 'boo', description: null}); 38 | t.equal(res.errors, undefined); 39 | }); 40 | 41 | graphql( 42 | createSchema({ 43 | sys: {type: 'Asset', id: 'aid', createdAt: 'dt1', updatedAt: 'dt2'}, 44 | fields: { 45 | title: 'xyz', 46 | description: 'asset desc', 47 | file: {url: 'http://some-url'} 48 | } 49 | }), 50 | '{ test { sys { id createdAt updatedAt } title description url } }' 51 | ).then(res => { 52 | t.deepEqual(res.data.test, { 53 | sys: { 54 | id: 'aid', 55 | createdAt: 'dt1', 56 | updatedAt: 'dt2' 57 | }, 58 | title: 'xyz', 59 | description: 'asset desc', 60 | url: 'http://some-url' 61 | }); 62 | t.equal(res.errors, undefined); 63 | }); 64 | 65 | t.end(); 66 | }); 67 | 68 | test('base-types: entry', function (t) { 69 | const createSchema = val => { 70 | return new GraphQLSchema({ 71 | query: new GraphQLObjectType({ 72 | name: 'Query', 73 | fields: { 74 | test: {type: EntryType, resolve: () => val}, 75 | impl: { 76 | type: new GraphQLObjectType({ 77 | name: 'Impl', 78 | fields: {sys: {type: EntrySysType}}, 79 | interfaces: [EntryType], 80 | isTypeOf: () => true 81 | }), 82 | resolve: () => val 83 | } 84 | } 85 | }) 86 | }); 87 | }; 88 | 89 | graphql(createSchema(null), '{ test { sys { id } } }').then(res => { 90 | t.equal(res.data.test, null); 91 | t.equal(res.errors, undefined); 92 | }); 93 | 94 | graphql( 95 | createSchema({sys: {type: 'Entry', id: 'eid'}}), 96 | '{ test { sys { id } } }' 97 | ).then(res => { 98 | t.deepEqual(res.data.test, {sys: {id: 'eid'}}); 99 | t.equal(res.errors, undefined); 100 | }); 101 | 102 | graphql( 103 | createSchema({ 104 | sys: { 105 | type: 'Entry', 106 | id: 'eid', 107 | createdAt: 'dt3', 108 | updatedAt: 'dt4', 109 | contentType: {sys: {id: 'ctid'}} 110 | } 111 | }), 112 | '{ test { sys { id createdAt updatedAt contentTypeId } } }' 113 | ).then(res => { 114 | t.deepEqual(res.data.test, { 115 | sys: { 116 | id: 'eid', 117 | createdAt: 'dt3', 118 | updatedAt: 'dt4', 119 | contentTypeId: 'ctid' 120 | } 121 | }); 122 | t.equal(res.errors, undefined); 123 | }); 124 | 125 | t.end(); 126 | }); 127 | 128 | test('base-types: location', function (t) { 129 | t.plan(2); 130 | 131 | const schema = new GraphQLSchema({ 132 | query: new GraphQLObjectType({ 133 | name: 'Query', 134 | fields: { 135 | test: { 136 | type: LocationType, 137 | resolve: () => ({lon: 11.1, lat: -22.2}) 138 | } 139 | } 140 | }) 141 | }); 142 | 143 | graphql(schema, '{ test { lat lon } }') 144 | .then(res => { 145 | t.deepEqual(res.data.test, { 146 | lat: -22.2, 147 | lon: 11.1 148 | }); 149 | t.equal(res.errors, undefined); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/client.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const sinon = require('sinon'); 5 | const proxyquire = require('proxyquire').noCallThru(); 6 | 7 | const httpStub = {get: sinon.stub().resolves({items: []})}; 8 | const createHttpClientStub = sinon.stub().returns(httpStub); 9 | const createEntryLoaderStub = sinon.stub(); 10 | 11 | const createClient = proxyquire('../src/client.js', { 12 | './http-client.js': createHttpClientStub, 13 | './entry-loader.js': createEntryLoaderStub 14 | }); 15 | 16 | const config = { 17 | spaceId: 'SPID', 18 | cdaToken: 'CDA-TOKEN', 19 | cmaToken: 'CMA-TOKEN' 20 | }; 21 | 22 | const client = createClient(config); 23 | 24 | test('client: config options', function (t) { 25 | t.plan(6); 26 | 27 | const p1 = client.getContentTypes(); 28 | client.createEntryLoader(); 29 | 30 | const alt = {secure: false, domain: 'altdomain.com'}; 31 | const c2 = createClient(Object.assign({}, config, alt)); 32 | const p2 = c2.getContentTypes(); 33 | c2.createEntryLoader(); 34 | 35 | const preview = {preview: true, cpaToken: 'CPA-TOKEN'}; 36 | const c3 = createClient(Object.assign({}, config, preview)); 37 | const p3 = c3.getContentTypes(); 38 | c3.createEntryLoader(); 39 | 40 | const assertCall = (i, base, token) => { 41 | t.deepEqual(createHttpClientStub.getCall(i).args, [{ 42 | base, 43 | headers: {Authorization: `Bearer ${token}-TOKEN`}, 44 | defaultParams: {} 45 | }]); 46 | }; 47 | 48 | Promise.all([p1, p2, p3]).then(() => { 49 | assertCall(0, 'https://api.contentful.com/spaces/SPID', 'CMA'); 50 | assertCall(1, 'https://cdn.contentful.com/spaces/SPID', 'CDA'); 51 | assertCall(2, 'http://api.altdomain.com/spaces/SPID', 'CMA'); 52 | assertCall(3, 'http://cdn.altdomain.com/spaces/SPID', 'CDA'); 53 | assertCall(4, 'https://api.contentful.com/spaces/SPID', 'CMA'); 54 | assertCall(5, 'https://preview.contentful.com/spaces/SPID', 'CPA'); 55 | }); 56 | }); 57 | 58 | test('client: with "locale" config option', function (t) { 59 | t.plan(2); 60 | createHttpClientStub.reset(); 61 | createHttpClientStub.returns(httpStub); 62 | 63 | const c = createClient(Object.assign({locale: 'x'}, config)); 64 | const defaultParams = n => createHttpClientStub.getCall(n).args[0].defaultParams; 65 | 66 | c.createEntryLoader(); 67 | c.getContentTypes() 68 | .then(() => { 69 | t.deepEqual(defaultParams(0), {locale: 'x'}); 70 | t.deepEqual(defaultParams(1), {}); 71 | }); 72 | }); 73 | 74 | test('client: getting content types', function (t) { 75 | t.plan(3); 76 | httpStub.get.resolves({items: [1, {}, 3]}); 77 | 78 | client.getContentTypes() 79 | .then(cts => { 80 | t.deepEqual(httpStub.get.firstCall.args, ['/content_types', {limit: 1000}]); 81 | t.deepEqual(cts, [1, {}, 3]); 82 | 83 | return Promise.all([ 84 | Promise.resolve(createHttpClientStub.callCount), 85 | client.getContentTypes() 86 | ]); 87 | }) 88 | .then(([count]) => t.equal(createHttpClientStub.callCount, count+1)); 89 | }); 90 | 91 | test('client: entry loader creation', function (t) { 92 | const entryLoader = {}; 93 | createEntryLoaderStub.returns(entryLoader); 94 | 95 | t.equal(client.createEntryLoader(), entryLoader); 96 | t.deepEqual(createEntryLoaderStub.firstCall.args, [httpStub]); 97 | 98 | t.end(); 99 | }); 100 | -------------------------------------------------------------------------------- /test/entry-loader.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const sinon = require('sinon'); 5 | 6 | const createEntryLoader = require('../src/entry-loader.js'); 7 | 8 | const ITEMS = [ 9 | {sys: {id: 'xyz'}}, 10 | {sys: {id: 'abc', contentType: {sys: {id: 'ctid'}}}} 11 | ]; 12 | 13 | const prepare = () => { 14 | const getStub = sinon.stub(); 15 | const httpStub = {get: getStub}; 16 | getStub.resolves({items: ITEMS, total: ITEMS.length}); 17 | return {httpStub, loader: createEntryLoader(httpStub)}; 18 | }; 19 | 20 | test('entry-loader: getting one entry', function (t) { 21 | t.plan(5); 22 | const {httpStub, loader} = prepare(); 23 | 24 | const p1 = loader.get('xyz') 25 | .then(entry => t.deepEqual(entry, {sys: {id: 'xyz'}})); 26 | 27 | const p2 = loader.get('xyz', 'ctid') 28 | .catch(err => t.ok(err.message.match(/forced content type/i))); 29 | 30 | const p3 = loader.get('abc', 'ctid') 31 | .then(entry => t.equal(entry.sys.id, 'abc')); 32 | 33 | Promise.all([p1, p2, p3]) 34 | .then(() => { 35 | const {args} = httpStub.get.firstCall; 36 | t.equal(args[0], '/entries'); 37 | t.deepEqual(args[1]['sys.id[in]'].split(',').sort(), ['abc', 'xyz']); 38 | }); 39 | }); 40 | 41 | test('entry-loader: getting many entries', function (t) { 42 | t.plan(1); 43 | const {loader} = prepare(); 44 | 45 | loader.getMany(['xyz', 'lol', 'abc']) 46 | .then(items => t.deepEqual(items, [ITEMS[0], undefined, ITEMS[1]])); 47 | }); 48 | 49 | test('entry-loader: querying entries', function (t) { 50 | t.plan(3); 51 | const {httpStub, loader} = prepare(); 52 | 53 | loader.query('ctid', {q: 'fields.someNum=123&fields.test[exists]=true'}) 54 | .then(res => { 55 | t.deepEqual(res, ITEMS); 56 | t.equal(httpStub.get.callCount, 1); 57 | t.deepEqual(httpStub.get.lastCall.args, ['/entries', { 58 | skip: 0, 59 | limit: 50, 60 | include: 1, 61 | content_type: 'ctid', 62 | 'fields.someNum': '123', 63 | 'fields.test[exists]': 'true' 64 | }]); 65 | }); 66 | }); 67 | 68 | test('entry-loader: counting entries', function (t) { 69 | t.plan(3); 70 | const {httpStub, loader} = prepare(); 71 | 72 | loader.count('ctid', {q: 'fields.test=hello'}) 73 | .then(count => { 74 | t.equal(count, ITEMS.length); 75 | t.equal(httpStub.get.callCount, 1); 76 | t.equal(httpStub.get.lastCall.args[1]['fields.test'], 'hello'); 77 | }); 78 | }); 79 | 80 | test('entry-loader: querying entries with custom skip/limit', function (t) { 81 | t.plan(2); 82 | const {httpStub, loader} = prepare(); 83 | 84 | loader.query('ctid', {skip: 1, limit: 2, q: 'x=y'}) 85 | .then(() => { 86 | t.equal(httpStub.get.callCount, 1); 87 | t.deepEqual(httpStub.get.lastCall.args, ['/entries', { 88 | skip: 1, 89 | limit: 2, 90 | include: 1, 91 | content_type: 'ctid', 92 | x: 'y' 93 | }]); 94 | }); 95 | }); 96 | 97 | test('entry-loader: using forbidden query parameters in QS', function (t) { 98 | const {httpStub, loader} = prepare(); 99 | ['skip', 'limit', 'include', 'content_type', 'locale'].forEach(key => { 100 | t.throws( 101 | () => loader.query('ctid', {q: `x=y&${key}=value`}), 102 | /query param named/i 103 | ); 104 | }); 105 | t.equal(httpStub.get.callCount, 0); 106 | t.end(); 107 | }); 108 | 109 | test('entry-loader: getting all entries of a content type', function (t) { 110 | t.plan(7); 111 | const {httpStub, loader} = prepare(); 112 | 113 | const ids = Array.apply(null, {length: 3001}).map((_, i) => `e${i+1}`); 114 | const entries = ids.map(id => ({sys: {id}})); 115 | 116 | // the last slice checks if we remove duplicates 117 | [[0, 1000], [1000, 2000], [2000, 3000], [2999]].forEach((slice, n) => { 118 | const items = entries.slice.apply(entries, slice); 119 | httpStub.get.onCall(n).resolves({total: 3001, items}); 120 | }); 121 | 122 | loader.queryAll('ctid') 123 | .then(items => { 124 | const callParams = n => httpStub.get.getCall(n).args[1]; 125 | const pageParams = n => ({limit: callParams(n).limit, skip: callParams(n).skip}); 126 | 127 | [0, 1, 2, 3].forEach(n => t.deepEqual(pageParams(n), {limit: 1000, skip: n*1000})); 128 | t.equal(httpStub.get.callCount, 4); 129 | 130 | t.equal(items.length, 3001); 131 | t.deepEqual(items.map(i => i.sys.id), ids); 132 | }); 133 | }); 134 | 135 | test('entry-loader: including assets', function (t) { 136 | t.plan(5); 137 | const {httpStub, loader} = prepare(); 138 | 139 | const includesValues = [ 140 | {Asset: [{sys: {id: 'a1'}}]}, 141 | undefined, 142 | {Asset: [{sys: {id: 'a2'}}, {sys: {id: 'a3'}}]} 143 | ]; 144 | 145 | includesValues.forEach((includes, n) => { 146 | httpStub.get.onCall(n).resolves({items: [], includes}); 147 | }); 148 | 149 | Promise.all([loader.get('e1'), loader.query('ctid'), loader.queryAll('ctid2')]) 150 | .then(() => { 151 | t.equal(httpStub.get.callCount, 3); 152 | ['a1', 'a2', 'a3'].forEach(id => { 153 | t.deepEqual(loader.getIncludedAsset(id), {sys: {id}}); 154 | }); 155 | t.equal(loader.getIncludedAsset('e1', undefined)); 156 | }); 157 | }); 158 | 159 | test('entry-loader: timeline', function (t) { 160 | const {httpStub, loader} = prepare(); 161 | const tl = {}; 162 | httpStub.timeline = tl; 163 | t.equal(loader.getTimeline(), tl); 164 | t.end(); 165 | }); 166 | -------------------------------------------------------------------------------- /test/field-config.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const { 6 | GraphQLString, 7 | GraphQLInt, 8 | GraphQLFloat, 9 | GraphQLBoolean, 10 | GraphQLList, 11 | getNamedType 12 | } = require('graphql'); 13 | 14 | const {AssetType, EntryType, LocationType} = require('../src/base-types.js'); 15 | const map = require('../src/field-config.js'); 16 | 17 | const entities = { 18 | asset: {foo: {}, bar: {}}, 19 | entry: {baz: {}, qux: {}} 20 | }; 21 | 22 | const ctx = { 23 | entryLoader: { 24 | getIncludedAsset: id => entities.asset[id], 25 | get: id => entities.entry[id], 26 | getMany: ids => Promise.resolve(ids.map(id => entities.entry[id])) 27 | } 28 | }; 29 | 30 | test('field-config: simple types', function (t) { 31 | const tests = [ 32 | ['String', GraphQLString, ['hello', '']], 33 | ['Int', GraphQLInt, [1, 0, -1]], 34 | ['Float', GraphQLFloat, [1, 1.1, 0, 0.1, -0.1, 2]], 35 | ['Bool', GraphQLBoolean, [false, true]] 36 | ]; 37 | 38 | tests.forEach(([key, Type, vals]) => { 39 | const config = map[key]({id: 'test'}); 40 | t.equal(config.type, Type); 41 | t.equal(config.resolve({fields: {}}), undefined); 42 | 43 | vals.concat([null, undefined]).forEach(val => { 44 | const resolved = config.resolve({fields: {test: val}}); 45 | t.equal(resolved, val); 46 | }); 47 | }); 48 | 49 | t.end(); 50 | }); 51 | 52 | test('field-config: object', function (t) { 53 | const config = map.Object({id: 'test'}); 54 | t.equal(config.type, GraphQLString); 55 | t.equal(config.resolve({fields: {}}), undefined); 56 | 57 | const value = {test: true, test2: false, nested: [123, 'xyz']}; 58 | const resolved = config.resolve({fields: {test: value}}); 59 | t.equal(typeof resolved, 'string'); 60 | t.deepEqual(JSON.parse(resolved), value); 61 | 62 | t.end(); 63 | }); 64 | 65 | test('field-config: location', function (t) { 66 | const config = map.Location({id: 'test'}); 67 | t.equal(config.type, LocationType); 68 | t.equal(config.resolve({fields: {}}), undefined); 69 | 70 | const location = {lon: 11.1, lat: -22.2}; 71 | const resolved = config.resolve({fields: {test: location}}); 72 | t.equal(typeof resolved, 'object'); 73 | t.deepEqual(resolved, {lon: 11.1, lat: -22.2}); 74 | 75 | t.end(); 76 | }); 77 | 78 | test('field-config: array of strings', function (t) { 79 | const config = map['Array']({id: 'test'}); 80 | t.ok(config.type instanceof GraphQLList); 81 | t.equal(getNamedType(config.type), GraphQLString); 82 | t.equal(config.resolve({fields: {}}), undefined); 83 | 84 | [[], ['x'], ['x', 'y'], null, undefined].forEach(val => { 85 | const resolved = config.resolve({fields: {test: val}}); 86 | t.equal(resolved, val); 87 | }); 88 | 89 | t.end(); 90 | }); 91 | 92 | test('field-config: links', function (t) { 93 | const assetConfig = map['Link']({id: 'test'}); 94 | t.equal(assetConfig.type, AssetType); 95 | t.equal(assetConfig.resolve({fields: {}}, null, ctx), undefined); 96 | 97 | const entryConfig = map['Link']({id: 'test'}); 98 | t.equal(entryConfig.type, EntryType); 99 | t.equal(entryConfig.resolve({fields: {}}, null, ctx), undefined); 100 | 101 | const tests = [ 102 | [assetConfig, {sys: {id: 'foo'}}, entities.asset.foo], 103 | [assetConfig, {sys: {id: 'bar'}}, entities.asset.bar], 104 | [assetConfig, {sys: {id: 'poop'}}, undefined], 105 | [assetConfig, null, undefined], 106 | [entryConfig, {sys: {id: 'baz'}}, entities.entry.baz], 107 | [entryConfig, {sys: {id: 'qux'}}, entities.entry.qux], 108 | [entryConfig, {sys: {id: 'lol'}}, undefined], 109 | [entryConfig, null, undefined] 110 | ]; 111 | 112 | tests.forEach(([config, link, val]) => { 113 | const resolved = config.resolve({fields: {test: link}}, null, ctx); 114 | t.equal(resolved, val); 115 | }); 116 | 117 | t.end(); 118 | }); 119 | 120 | test('field-config: type for linked entry', function (t) { 121 | const types = {ct1: {}, ct2: {}}; 122 | const tests = [ 123 | [{}, undefined, EntryType], 124 | [{linkedCt: 'ct1'}, undefined, EntryType], 125 | [{linkedCt: 'ct2'}, {ct1: types.ct1, ct2: types.ct2}, types.ct2], 126 | [{linkedCt: 'ct3'}, {ct1: types.ct1, ct2: types.ct2}, EntryType] 127 | ]; 128 | 129 | tests.forEach(([field, ctIdToType, Type]) => { 130 | const config = map['Link'](field, ctIdToType); 131 | t.equal(config.type, Type); 132 | }); 133 | 134 | t.end(); 135 | }); 136 | 137 | test('field-config: arrays of links', function (t) { 138 | const assetConfig = map['Array>']({id: 'test'}); 139 | const entryConfig = map['Array>']({id: 'test'}); 140 | 141 | [[assetConfig, AssetType], [entryConfig, EntryType]].forEach(([config, Type]) => { 142 | t.ok(config.type instanceof GraphQLList); 143 | t.equal(getNamedType(config.type), Type); 144 | t.equal(config.resolve({fields: {}}), undefined); 145 | t.equal(config.resolve({fields: {test: null}}), undefined); 146 | t.deepEqual(config.resolve({fields: {test: []}}, null, ctx), []); 147 | }); 148 | 149 | const links = [ 150 | {sys: {id: 'poop'}}, 151 | {sys: {id: 'bar'}}, 152 | {sys: {id: 'qux'}}, 153 | null, 154 | {sys: {id: 'foo'}}, 155 | {sys: {id: 'baz'}} 156 | ]; 157 | 158 | const resolvedAssets = assetConfig.resolve({fields: {test: links}}, null, ctx); 159 | t.deepEqual(resolvedAssets, [entities.asset.bar, entities.asset.foo]); 160 | 161 | entryConfig.resolve({fields: {test: links}}, null, ctx) 162 | .then(resolvedEntries => { 163 | t.deepEqual(resolvedEntries, [entities.entry.qux, entities.entry.baz]); 164 | }); 165 | 166 | t.end(); 167 | }); 168 | -------------------------------------------------------------------------------- /test/helpers/express-graphql-extension.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const createExtension = require('../../src/helpers/express-graphql-extension.js'); 6 | 7 | const loader = {getTimeline: () => [{url: '/test', start: 10, duration: 20}]}; 8 | const client = {createEntryLoader: () => loader}; 9 | const schema = {}; 10 | 11 | test('express-graphql-extension: default options', function (t) { 12 | 13 | const extension = createExtension(client, schema); 14 | 15 | t.deepEqual(extension(), { 16 | context: {entryLoader: loader}, 17 | schema, 18 | graphiql: false, 19 | extensions: undefined, 20 | formatError: undefined 21 | }); 22 | 23 | t.end(); 24 | }); 25 | 26 | test('express-graphql-extension: cf-graphql version extension', function (t) { 27 | const extension = createExtension(client, schema, {version: true}); 28 | const extensions = extension().extensions(); 29 | 30 | t.deepEqual(extensions['cf-graphql'], { 31 | version: require('../../package.json').version 32 | }); 33 | 34 | t.end(); 35 | }); 36 | 37 | test('express-graphql-extension: timeline extension', function (t) { 38 | const extension = createExtension(client, schema, {timeline: true}); 39 | const extensions = extension().extensions(); 40 | 41 | t.equal(typeof extensions.time, 'number'); 42 | t.ok(extensions.time >= 0); 43 | 44 | t.ok(Array.isArray(extensions.timeline)); 45 | t.equal(extensions.timeline.length, 1); 46 | 47 | const first = extensions.timeline[0]; 48 | t.deepEqual(Object.keys(first).sort(), ['url', 'start', 'duration'].sort()); 49 | t.ok(first.start <= 10); 50 | 51 | t.end(); 52 | }); 53 | 54 | test('express-graphql-extension: detailed errors', function (t) { 55 | const extension = createExtension(client, schema, {detailedErrors: true}); 56 | const {formatError} = extension(); 57 | 58 | t.equal(typeof formatError, 'function'); 59 | 60 | const err = new Error('test'); 61 | const stack = err.stack; 62 | err.locations = 'LOCS'; 63 | err.path = 'PATH'; 64 | 65 | t.deepEqual(formatError(err), { 66 | message: 'test', 67 | locations: 'LOCS', 68 | path: 'PATH', 69 | stack 70 | }); 71 | 72 | t.end(); 73 | }); 74 | -------------------------------------------------------------------------------- /test/helpers/graphiql.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const getResponse = require('../../src/helpers/graphiql.js'); 6 | 7 | test('ui: headers and status', function (t) { 8 | const {statusCode, headers} = getResponse(); 9 | t.equal(statusCode, 200); 10 | t.deepEqual(headers, { 11 | 'Content-Type': 'text/html; charset=utf-8', 12 | 'Cache-Control': 'no-cache' 13 | }); 14 | t.end(); 15 | }); 16 | 17 | test('ui: body', function (t) { 18 | const endpoints = [ 19 | 'http://localhost/graphql-endpoint', 20 | 'https://remote.com/graphql', 21 | '/test' 22 | ]; 23 | 24 | endpoints.forEach(url => { 25 | const {body} = getResponse({url}); 26 | t.ok(body.includes(url)); 27 | }); 28 | 29 | ['test', 'demo'].forEach(title => { 30 | const {body} = getResponse({title}); 31 | t.ok(body.includes(`${title}`)); 32 | }); 33 | 34 | t.end(); 35 | }); 36 | 37 | test('ui: default options', function (t) { 38 | const {body} = getResponse(); 39 | t.ok(body.includes('/graphql')); 40 | t.ok(body.includes('GraphiQL')); 41 | t.end(); 42 | }); 43 | -------------------------------------------------------------------------------- /test/helpers/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const helpers = require('../../src/helpers'); 6 | 7 | test('helpers: imports all functions successfully', function (t) { 8 | ['graphiql', 'expressGraphqlExtension'].forEach(m => { 9 | t.ok(typeof helpers[m], 'function'); 10 | }); 11 | 12 | t.end(); 13 | }); 14 | -------------------------------------------------------------------------------- /test/http-client.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const sinon = require('sinon'); 5 | const proxyquire = require('proxyquire').noCallThru(); 6 | 7 | const stubs = { 8 | 'node-fetch': sinon.stub(), 9 | platform: sinon.stub().returns(null), 10 | release: sinon.stub().returns(null) 11 | }; 12 | 13 | const createClient = proxyquire('../src/http-client.js', { 14 | 'node-fetch': stubs['node-fetch'], 15 | os: {platform: stubs.platform, release: stubs.release} 16 | }); 17 | 18 | const prepare = (val, defaultParams = {}) => { 19 | const fetch = stubs['node-fetch']; 20 | fetch.reset(); 21 | fetch.resolves(val || {status: 200, json: () => true}); 22 | 23 | return { 24 | fetch, 25 | http: createClient({ 26 | base: 'http://test.com', 27 | headers: {'X-Test': 'yes'}, 28 | defaultParams 29 | }) 30 | }; 31 | }; 32 | 33 | const mockNodeVersion = ver => { 34 | const original = process.versions; 35 | Object.defineProperty(process, 'versions', {value: {node: ver}}); 36 | return () => Object.defineProperty(process, 'versions', original); 37 | }; 38 | 39 | test('http-client: using base and headers', function (t) { 40 | t.plan(4); 41 | const {fetch, http} = prepare(); 42 | 43 | http.get('/endpoint') 44 | .then(res => { 45 | t.equal(res, true); 46 | t.equal(fetch.callCount, 1); 47 | t.equal(fetch.firstCall.args[0], 'http://test.com/endpoint'); 48 | t.equal(fetch.firstCall.args[1].headers['X-Test'], 'yes'); 49 | }); 50 | }); 51 | 52 | test('http-client: using default params option', function (t) { 53 | t.plan(3); 54 | const {fetch, http} = prepare(undefined, {locale: 'de-DE'}); 55 | 56 | Promise.all([ 57 | http.get('/x', {content_type: 'ctid'}), 58 | http.get('/y', {locale: 'x'}) 59 | ]) 60 | .then(() => { 61 | t.equal(fetch.callCount, 2); 62 | t.deepEqual(fetch.firstCall.args[0].split('?')[1], 'content_type=ctid&locale=de-DE'); 63 | t.deepEqual(fetch.lastCall.args[0].split('?')[1], 'locale=x'); 64 | }); 65 | }); 66 | 67 | test('http-client: defaults', function (t) { 68 | t.plan(1); 69 | const {fetch} = prepare(); 70 | 71 | createClient() 72 | .get('http://some-api.com/endpoint') 73 | .then(() => t.equal(fetch.firstCall.args[0], 'http://some-api.com/endpoint')); 74 | }); 75 | 76 | test('http-client: non 2xx response codes', function (t) { 77 | t.plan(3); 78 | const {fetch, http} = prepare({status: 199, statusText: 'BOOM!'}); 79 | 80 | http.get('/err') 81 | .catch(err => { 82 | t.equal(fetch.callCount, 1); 83 | t.ok(err instanceof Error); 84 | t.deepEqual(err, {response: {status: 199, statusText: 'BOOM!'}}); 85 | }); 86 | }); 87 | 88 | test('http-client: reuses already fired requests', function (t) { 89 | t.plan(2); 90 | const {fetch, http} = prepare(); 91 | 92 | const p1 = http.get('/one'); 93 | const p2 = http.get('/one'); 94 | 95 | Promise.all([p1, http.get('/two'), p2]) 96 | .then(() => { 97 | t.equal(p1, p2); 98 | t.equal(fetch.callCount, 2); 99 | }); 100 | }); 101 | 102 | test('http-client: sorts parameters', function (t) { 103 | t.plan(4); 104 | const {fetch, http} = prepare(); 105 | 106 | const p1 = http.get('/one', {z: 123, a: 456}); 107 | const p2 = http.get('/one', {a: 456, z: 123}); 108 | 109 | Promise.all([p1, http.get('/two', {omega: true, alfa: false}), p2]) 110 | .then(() => { 111 | t.equal(p1, p2); 112 | t.equal(fetch.callCount, 2); 113 | t.equal(fetch.firstCall.args[0], 'http://test.com/one?a=456&z=123'); 114 | t.equal(fetch.secondCall.args[0], 'http://test.com/two?alfa=false&omega=true'); 115 | }); 116 | }); 117 | 118 | test('http-client: timeline', function (t) { 119 | t.plan(5); 120 | const {http} = prepare(); 121 | 122 | Promise.all([http.get('/one'), http.get('/two')]) 123 | .then(() => { 124 | t.equal(http.timeline.length, 2); 125 | const [t1, t2] = http.timeline; 126 | t.equal(t1.url, '/one'); 127 | t.equal(t2.url, '/two'); 128 | t.ok(t1.start <= t2.start); 129 | t.ok(typeof t1.duration === 'number'); 130 | }); 131 | }); 132 | 133 | test('http-client: minimal User Agent header', function (t) { 134 | t.plan(2); 135 | const restore = mockNodeVersion(null); 136 | const {http, fetch} = prepare(); 137 | 138 | http.get('/test') 139 | .then(() => { 140 | const headers = fetch.firstCall.args[1].headers; 141 | const userAgent = headers['X-Contentful-User-Agent']; 142 | t.equal(headers['X-Test'], 'yes'); 143 | t.equal(userAgent, 'app contentful.cf-graphql;'); 144 | restore(); 145 | }); 146 | }); 147 | 148 | test('http-client: User Agent OS and platform header', function (t) { 149 | t.plan(1); 150 | stubs.platform.returns('darwin'); 151 | stubs.release.returns('x.y.z'); 152 | const restore = mockNodeVersion('10.0.0'); 153 | const {http, fetch} = prepare(); 154 | 155 | http.get('/test') 156 | .then(() => { 157 | const userAgent = fetch.firstCall.args[1].headers['X-Contentful-User-Agent']; 158 | t.deepEqual(userAgent.split('; '), [ 159 | 'app contentful.cf-graphql', 160 | 'os macOS/x.y.z', 161 | 'platform node.js/10.0.0;' 162 | ]); 163 | restore(); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const index = require('..'); 6 | 7 | test('index: all methods/wrappers successfully imported', function (t) { 8 | const methods = [ 9 | 'createClient', 10 | 'prepareSpaceGraph', 11 | 'createSchema', 12 | 'createQueryType', 13 | 'createQueryFields' 14 | ]; 15 | 16 | const wrappers = ['helpers']; 17 | 18 | methods.forEach(m => t.equal(typeof index[m], 'function')); 19 | wrappers.forEach(w => t.equal(typeof index[w], 'object')); 20 | 21 | t.equal( 22 | Object.keys(index).length, 23 | methods.length + wrappers.length 24 | ); 25 | 26 | t.equal(index.helpers, require('../src/helpers')); 27 | 28 | t.end(); 29 | }); 30 | -------------------------------------------------------------------------------- /test/prepare-space-graph.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | const prepareSpaceGraph = require('../src/prepare-space-graph.js'); 6 | 7 | const testCt = fields => ({sys: {id: 'ctid'}, name: 'test', fields}); 8 | 9 | test('prepare-space-graph: names', function (t) { 10 | const [p1, p2] = prepareSpaceGraph([ 11 | {sys: {id: 'ctid'}, name: 'test entity', fields: []}, 12 | {sys: {id: 'ctid2'}, name: 'BlogPost!', fields: []} 13 | ]); 14 | 15 | t.equal(p1.id, 'ctid'); 16 | t.deepEqual(p1.names, { 17 | field: 'testEntity', 18 | collectionField: 'testEntities', 19 | type: 'TestEntity', 20 | backrefsType: 'TestEntityBackrefs' 21 | }); 22 | t.deepEqual(p1.fields, []); 23 | 24 | t.equal(p2.id, 'ctid2'); 25 | t.deepEqual(p2.names, { 26 | field: 'blogPost', 27 | collectionField: 'blogPosts', 28 | type: 'BlogPost', 29 | backrefsType: 'BlogPostBackrefs' 30 | }); 31 | t.deepEqual(p2.fields, []); 32 | 33 | t.end(); 34 | }); 35 | 36 | test('prepare-space-graph: conflicing names', function (t) { 37 | const prepare1 = () => prepareSpaceGraph([ 38 | {sys: {id: 'ctid1'}, name: 'Test-', fields: []}, 39 | {sys: {id: 'ctid2'}, name: 'Test_', fields: []} 40 | ]); 41 | 42 | const prepare2 = () => prepareSpaceGraph([ 43 | {sys: {id: 'ctid1'}, name: 'Test1', fields: []}, 44 | {sys: {id: 'ctid2'}, name: 'Test2', fields: []} 45 | ]); 46 | 47 | t.throws(prepare1, /Conflicing name: "test"\. Type of name: "field"/); 48 | t.doesNotThrow(prepare2); 49 | 50 | t.end(); 51 | }); 52 | 53 | test('prepare-space-graph: skipping omitted fields', function (t) { 54 | const [p] = prepareSpaceGraph([testCt([ 55 | {id: 'f1', type: 'Text', omitted: false}, 56 | {id: 'f2', type: 'Text', omitted: true}, 57 | {id: 'f3', type: 'Text'} 58 | ])]); 59 | 60 | t.equal(p.fields.length, 2); 61 | t.deepEqual(p.fields.map(f => f.id), ['f1', 'f3']); 62 | 63 | t.end(); 64 | }); 65 | 66 | test('prepare-space-graph: throws on unsupported field names', function (t) { 67 | ['sys', '_backrefs'].forEach(id => { 68 | const ct = testCt([{id, type: 'Text'}]); 69 | t.throws(() => prepareSpaceGraph([ct]), /are unsupported/); 70 | }); 71 | 72 | t.end(); 73 | }); 74 | 75 | test('prepare-space-graph: array field types', function (t) { 76 | const [p] = prepareSpaceGraph([testCt([ 77 | {id: 'f1', type: 'Array', items: {type: 'Symbol'}}, 78 | {id: 'f2', type: 'Array', items: {type: 'Link', linkType: 'Entry'}}, 79 | {id: 'f3', type: 'Array', items: {type: 'Link', linkType: 'Asset'}} 80 | ])]); 81 | 82 | t.deepEqual(p.fields.map(f => f.type), [ 83 | 'Array', 84 | 'Array>', 85 | 'Array>' 86 | ]); 87 | 88 | [{type: 'x'}, {type: 'Link'}, {type: 'Link', linkType: 'x'}].forEach(items => { 89 | const ct = testCt([{id: 'fid', type: 'Array', items}]); 90 | t.throws(() => prepareSpaceGraph([ct]), /type "Array"/); 91 | }); 92 | 93 | t.end(); 94 | }); 95 | 96 | test('prepare-space-graph: link field types', function (t) { 97 | const [p] = prepareSpaceGraph([testCt([ 98 | {id: 'f1', type: 'Link', linkType: 'Entry'}, 99 | {id: 'f2', type: 'Link', linkType: 'Asset'} 100 | ])]); 101 | 102 | t.deepEqual(p.fields.map(f => f.type), ['Link', 'Link']); 103 | 104 | ['x', null, undefined].forEach(linkType => { 105 | const ct = testCt([{id: 'fid', type: 'Link', linkType}]); 106 | t.throws(() => prepareSpaceGraph([ct]), /type "Link"/); 107 | }); 108 | 109 | t.end(); 110 | }); 111 | 112 | test('prepare-space-graph: simple field types', function (t) { 113 | const mapping = { 114 | Symbol: 'String', 115 | Text: 'String', 116 | Number: 'Float', 117 | Integer: 'Int', 118 | Date: 'String', 119 | Boolean: 'Bool', 120 | Location: 'Location', 121 | Object: 'Object' 122 | }; 123 | 124 | const keys = Object.keys(mapping); 125 | const values = keys.map(key => mapping[key]); 126 | const fields = keys.reduce((acc, type, i) => { 127 | return acc.concat([{id: `f${i}`, type}]); 128 | }, []); 129 | 130 | const [p] = prepareSpaceGraph([testCt(fields)]); 131 | 132 | t.deepEqual(p.fields.map(f => f.type), values); 133 | 134 | ['x', null, undefined].forEach(type => { 135 | const ct = testCt([{id: 'fid', type}]); 136 | t.throws(() => prepareSpaceGraph([ct]), /Unknown field type/); 137 | }); 138 | 139 | t.end(); 140 | }); 141 | 142 | test('prepare-space-graph: finding linked content types', function (t) { 143 | const tests = [ 144 | undefined, 145 | [], 146 | [{linkContentType: ['foo', 'bar']}], 147 | [{unique: true}, {linkContentType: ['baz']}, {}] 148 | ]; 149 | 150 | const fields = tests.reduce((acc, validations, i) => { 151 | return acc.concat([ 152 | {id: `fl${i}`, type: 'Link', linkType: 'Entry', validations}, 153 | {id: `fa${i}`, type: 'Array', items: {type: 'Link', linkType: 'Entry', validations}} 154 | ]); 155 | }, []); 156 | 157 | const [p] = prepareSpaceGraph([testCt(fields)]); 158 | const linkedCts = p.fields.map(f => f.linkedCt).filter(id => typeof id === 'string'); 159 | 160 | t.deepEqual(linkedCts, ['baz', 'baz']); 161 | 162 | t.end(); 163 | }); 164 | 165 | test('prepare-space-graph: mixing field and items validations', function (t) { 166 | const items = {type: 'Link', linkType: 'Entry', validations: [{linkContentType: ['ctid']}]}; 167 | const fields = [{id: 'fid', type: 'Array', validations: [], items}]; 168 | const [p] = prepareSpaceGraph([testCt(fields)]); 169 | 170 | t.equal(p.fields[0].linkedCt, 'ctid'); 171 | 172 | t.end(); 173 | }); 174 | 175 | test('prepare-space-graph: adding backreferences', function (t) { 176 | const cts = [ 177 | { 178 | sys: {id: 'post'}, 179 | name: 'post', 180 | fields: [ 181 | {id: 'author', type: 'Link', linkType: 'Entry', validations: [{linkContentType: ['author']}]} 182 | ] 183 | }, 184 | { 185 | sys: {id: 'author'}, 186 | name: 'author', 187 | fields: [] 188 | } 189 | ]; 190 | 191 | const [pPost, pAuthor] = prepareSpaceGraph(cts); 192 | t.equal(pPost._backrefs, undefined); 193 | t.deepEqual(pAuthor.backrefs, [{ 194 | ctId: 'post', 195 | fieldId: 'author', 196 | backrefFieldName: 'posts__via__author' 197 | }]); 198 | 199 | t.end(); 200 | }); 201 | -------------------------------------------------------------------------------- /test/schema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const sinon = require('sinon'); 5 | 6 | const { 7 | graphql, 8 | GraphQLSchema, 9 | GraphQLObjectType 10 | } = require('graphql'); 11 | 12 | const { 13 | createSchema, 14 | createQueryType, 15 | createQueryFields 16 | } = require('../src/schema.js'); 17 | 18 | const postct = { 19 | id: 'postct', 20 | names: { 21 | field: 'post', 22 | collectionField: 'posts', 23 | type: 'Post' 24 | }, 25 | fields: [ 26 | {id: 'title', type: 'String'}, 27 | {id: 'content', type: 'String'}, 28 | {id: 'category', type: 'Link', linkedCt: 'catct'} 29 | ] 30 | }; 31 | 32 | test('schema: querying generated schema', function (t) { 33 | const spaceGraph = [ 34 | postct, 35 | { 36 | id: 'catct', 37 | names: { 38 | field: 'category', 39 | collectionField: 'categories', 40 | type: 'Category', 41 | backrefsType: 'CategoryBackrefs' 42 | }, 43 | fields: [ 44 | {id: 'name', type: 'String'} 45 | ], 46 | backrefs: [ 47 | {ctId: 'postct', fieldId: 'category', backrefFieldName: 'posts__via__category'} 48 | ] 49 | } 50 | ]; 51 | 52 | const schema = createSchema(spaceGraph); 53 | 54 | const post = { 55 | sys: {id: 'p1', contentType: {sys: {id: 'postct'}}}, 56 | fields: { 57 | title: 'Hello world', 58 | category: {sys: {id: 'c1'}} 59 | } 60 | }; 61 | 62 | const category = { 63 | sys: {id: 'c1', contentType: {sys: {id: 'catct'}}}, 64 | fields: {name: 'test'} 65 | }; 66 | 67 | const testQuery = (query, entryLoader) => { 68 | return graphql(schema, query, null, {entryLoader}) 69 | .then(res => [entryLoader, res]); 70 | }; 71 | 72 | t.plan(22); 73 | 74 | testQuery('{ posts { title } }', {query: sinon.stub().resolves([post])}) 75 | .then(([entryLoader, res]) => { 76 | t.deepEqual(entryLoader.query.firstCall.args, ['postct', {}]); 77 | t.equal(res.errors, undefined); 78 | t.deepEqual(res.data.posts, [{title: 'Hello world'}]); 79 | }); 80 | 81 | testQuery( 82 | '{ categories(skip: 2, limit: 3, q: "fields.name=test") { name } }', 83 | {query: sinon.stub().resolves([category])} 84 | ).then(([entryLoader, res]) => { 85 | t.deepEqual( 86 | entryLoader.query.firstCall.args, 87 | ['catct', {skip: 2, limit: 3, q: 'fields.name=test'}] 88 | ); 89 | t.equal(res.errors, undefined); 90 | t.deepEqual(res.data.categories, [{name: 'test'}]); 91 | }); 92 | 93 | testQuery( 94 | '{ post(id: "p1") { title category { name } } }', 95 | {get: sinon.stub().onCall(0).resolves(post).onCall(1).resolves(category)} 96 | ).then(([entryLoader, res]) => { 97 | t.equal(entryLoader.get.callCount, 2); 98 | t.deepEqual(entryLoader.get.firstCall.args, ['p1', 'postct']); 99 | t.deepEqual(entryLoader.get.lastCall.args, ['c1', 'catct']); 100 | t.equal(res.errors, undefined); 101 | t.deepEqual(res.data.post, {title: 'Hello world', category: {name: 'test'}}); 102 | }); 103 | 104 | testQuery( 105 | '{ posts { title } category(id: "c1") { name } }', 106 | {query: sinon.stub().resolves([post]), get: sinon.stub().resolves(category)} 107 | ).then(([entryLoader, res]) => { 108 | t.deepEqual(entryLoader.query.firstCall.args, ['postct', {}]); 109 | t.deepEqual(entryLoader.get.firstCall.args, ['c1', 'catct']); 110 | t.equal(res.errors, undefined); 111 | t.deepEqual(res.data, {posts: [{title: 'Hello world'}], category: {name: 'test'}}); 112 | }); 113 | 114 | testQuery( 115 | '{ categories { _backrefs { posts__via__category { title } } } }', 116 | {query: sinon.stub().resolves([category]), queryAll: sinon.stub().resolves([post])} 117 | ).then(([entryLoader, res]) => { 118 | t.deepEqual(entryLoader.query.firstCall.args, ['catct', {}]); 119 | t.deepEqual(entryLoader.queryAll.firstCall.args, ['postct']); 120 | t.equal(res.errors, undefined); 121 | t.deepEqual(res.data, {categories: [{_backrefs: {posts__via__category: [{title: 'Hello world'}]}}]}); 122 | }); 123 | 124 | testQuery( 125 | '{ _categoriesMeta(q: "sys.id[in]=1,2,3") { count } }', 126 | {count: sinon.stub().resolves(7)} 127 | ).then(([entryLoader, res]) => { 128 | t.deepEqual(entryLoader.count.firstCall.args, ['catct', {q: 'sys.id[in]=1,2,3'}]); 129 | t.equal(res.errors, undefined); 130 | t.deepEqual(res.data, {_categoriesMeta: {count: 7}}); 131 | }); 132 | }); 133 | 134 | test('schema: name of query type', function (t) { 135 | t.plan(6); 136 | 137 | ['Root', undefined].forEach(name => { 138 | const schema = createSchema([postct], name); 139 | const QueryType = createQueryType([postct], name); 140 | 141 | t.ok(QueryType instanceof GraphQLObjectType); 142 | 143 | const query = '{ __schema { queryType { name } } }'; 144 | const assertName = ({data}) => t.equal(data.__schema.queryType.name, name || 'Query'); 145 | 146 | graphql(schema, query).then(assertName); 147 | graphql(new GraphQLSchema({query: QueryType}), query).then(assertName); 148 | }); 149 | }); 150 | 151 | test('schema: producting query fields', function (t) { 152 | const queryFields = createQueryFields([postct]); 153 | 154 | t.equal(typeof queryFields, 'object'); 155 | t.deepEqual( 156 | Object.keys(queryFields).sort(), 157 | ['post', 'posts', '_postsMeta'].sort() 158 | ); 159 | 160 | t.end(); 161 | }); 162 | --------------------------------------------------------------------------------