├── .nvmrc
├── demo
├── .gitignore
├── src
│ ├── logo.png
│ ├── favicon.png
│ ├── index.js
│ ├── index.css
│ ├── index.html
│ └── App.js
├── demo-space-graph.png
├── .eslintrc.json
├── webpack.config.js
├── prepare-demo-data.js
├── package.json
├── server.js
└── demo-data.json
├── .gitignore
├── .istanbul.yml
├── src
├── helpers
│ ├── index.js
│ ├── express-graphql-extension.js
│ └── graphiql.js
├── index.js
├── client.js
├── backref-types.js
├── base-types.js
├── http-client.js
├── schema.js
├── field-config.js
├── entry-loader.js
└── prepare-space-graph.js
├── .travis.yml
├── test
├── helpers
│ ├── index.test.js
│ ├── graphiql.test.js
│ └── express-graphql-extension.test.js
├── index.test.js
├── backref-types.test.js
├── client.test.js
├── base-types.test.js
├── http-client.test.js
├── entry-loader.test.js
├── schema.test.js
├── field-config.test.js
└── prepare-space-graph.test.js
├── .eslintrc.json
├── dev
└── server.js
├── package.json
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 6.11.3
2 |
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /coverage
3 | /dump
4 | /dev/config.json
5 |
--------------------------------------------------------------------------------
/demo/src/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful/cf-graphql/master/demo/src/logo.png
--------------------------------------------------------------------------------
/demo/src/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful/cf-graphql/master/demo/src/favicon.png
--------------------------------------------------------------------------------
/demo/demo-space-graph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentful/cf-graphql/master/demo/demo-space-graph.png
--------------------------------------------------------------------------------
/.istanbul.yml:
--------------------------------------------------------------------------------
1 | instrumentation:
2 | include-all-sources: true
3 | excludes:
4 | - demo/**
5 | - dev/**
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cf-graphql
2 |
3 | [](https://travis-ci.org/contentful-labs/cf-graphql)
4 | [](https://www.npmjs.com/package/cf-graphql)
5 | [](https://www.npmjs.com/package/cf-graphql)
6 | [](https://david-dm.org/contentful-labs/cf-graphql)
7 | [](https://david-dm.org/contentful-labs/cf-graphql?type=dev)
8 | [](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 | 
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 |
--------------------------------------------------------------------------------