├── app ├── .gitkeep └── initializers │ └── graph-data.js ├── addon ├── .gitkeep ├── transport.js ├── utils.js ├── serializer.js └── adapter.js ├── vendor └── .gitkeep ├── tests ├── unit │ ├── .gitkeep │ ├── build │ │ ├── test-fragment.graphql │ │ ├── test-query.graphql │ │ └── graphql-filter-test.js │ ├── adapters │ │ └── application-test.js │ └── serializers │ │ └── application-test.js ├── integration │ └── .gitkeep ├── dummy │ ├── app │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── models │ │ │ ├── .gitkeep │ │ │ ├── admin-user.js │ │ │ ├── user │ │ │ │ ├── role.js │ │ │ │ └── blog-post.js │ │ │ └── user.js │ │ ├── routes │ │ │ ├── .gitkeep │ │ │ ├── users │ │ │ │ └── show.js │ │ │ └── users.js │ │ ├── styles │ │ │ └── app.css │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ ├── .gitkeep │ │ │ └── users.js │ │ ├── templates │ │ │ ├── components │ │ │ │ └── .gitkeep │ │ │ ├── application.hbs │ │ │ ├── users │ │ │ │ └── show.hbs │ │ │ └── users.hbs │ │ ├── resolver.js │ │ ├── graph │ │ │ ├── fragments │ │ │ │ └── user-fragment.graphql │ │ │ ├── queries │ │ │ │ ├── users.graphql │ │ │ │ └── user.graphql │ │ │ └── mutations │ │ │ │ └── user-create.graphql │ │ ├── serializers │ │ │ └── application.js │ │ ├── adapters │ │ │ └── application.js │ │ ├── router.js │ │ ├── app.js │ │ └── index.html │ ├── public │ │ ├── robots.txt │ │ └── crossdomain.xml │ ├── mirage │ │ ├── scenarios │ │ │ └── default.js │ │ ├── models │ │ │ └── user.js │ │ ├── serializers │ │ │ └── application.js │ │ ├── factories │ │ │ └── user.js │ │ └── config.js │ └── config │ │ ├── targets.js │ │ └── environment.js ├── .eslintrc.js ├── test-helper.js ├── helpers │ ├── destroy-app.js │ ├── resolver.js │ └── start-app.js ├── acceptance │ └── users-test.js └── index.html ├── .watchmanconfig ├── lib ├── .eslintrc.js └── graphql-filter.js ├── config ├── environment.js └── ember-try.js ├── .npmignore ├── .eslintrc.js ├── .ember-cli ├── .gitignore ├── testem.js ├── .editorconfig ├── ember-cli-build.js ├── LICENSE.md ├── .travis.yml ├── index.js ├── package.json └── README.md /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | embertest: true 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /tests/unit/build/test-fragment.graphql: -------------------------------------------------------------------------------- 1 | fragment testFragment on Object { 2 | name 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 |

Welcome to Ember

2 | 3 | {{outlet}} -------------------------------------------------------------------------------- /lib/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /tests/dummy/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /tests/dummy/mirage/scenarios/default.js: -------------------------------------------------------------------------------- 1 | export default function(server) { 2 | server.createList('user', 10) 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/users/show.hbs: -------------------------------------------------------------------------------- 1 |

User details

2 |

{{model.email}}

3 |

{{model.firstName}}

4 | -------------------------------------------------------------------------------- /tests/dummy/mirage/models/user.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'ember-cli-mirage' 2 | 3 | export default Model.extend({ 4 | }) 5 | -------------------------------------------------------------------------------- /tests/dummy/app/graph/fragments/user-fragment.graphql: -------------------------------------------------------------------------------- 1 | fragment UserFragment on User { 2 | id 3 | firstName 4 | email 5 | } 6 | -------------------------------------------------------------------------------- /tests/dummy/app/models/admin-user.js: -------------------------------------------------------------------------------- 1 | import User from './user' 2 | 3 | export default User.extend({ 4 | isAdmin: true 5 | }) 6 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import resolver from './helpers/resolver'; 2 | import { setResolver } from 'ember-mocha'; 3 | 4 | setResolver(resolver); 5 | -------------------------------------------------------------------------------- /tests/unit/build/test-query.graphql: -------------------------------------------------------------------------------- 1 | #import './test-fragment' 2 | 3 | query TestQuery { 4 | subject { 5 | ...testFragment 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/serializers/application.js: -------------------------------------------------------------------------------- 1 | import GraphSerializer from 'ember-graph-data/serializer' 2 | 3 | export default GraphSerializer.extend() 4 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function(/* environment, appConfig */) { 5 | return { }; 6 | }; 7 | -------------------------------------------------------------------------------- /tests/dummy/app/graph/queries/users.graphql: -------------------------------------------------------------------------------- 1 | #import '../fragments/user-fragment' 2 | 3 | query users { 4 | users { 5 | ...UserFragment 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/mirage/serializers/application.js: -------------------------------------------------------------------------------- 1 | import { JSONAPISerializer } from 'ember-cli-mirage'; 2 | 3 | export default JSONAPISerializer.extend({ 4 | }); 5 | -------------------------------------------------------------------------------- /tests/dummy/app/graph/queries/user.graphql: -------------------------------------------------------------------------------- 1 | #import '../fragments/user-fragment' 2 | 3 | mutation user($id: ID!) { 4 | user(id: $id) { 5 | ...UserFragment 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/models/user/role.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data' 2 | 3 | const { 4 | Model, 5 | attr 6 | } = DS 7 | 8 | export default Model.extend({ 9 | name: attr() 10 | }) 11 | -------------------------------------------------------------------------------- /tests/dummy/app/models/user/blog-post.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data' 2 | 3 | const { 4 | Model, 5 | attr 6 | } = DS 7 | 8 | export default Model.extend({ 9 | title: attr(), 10 | body: attr() 11 | }) 12 | -------------------------------------------------------------------------------- /tests/helpers/destroy-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default function destroyApp(application) { 4 | Ember.run(application, 'destroy'); 5 | if (window.server) { 6 | window.server.shutdown(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | browsers: [ 4 | 'ie 9', 5 | 'last 1 Chrome versions', 6 | 'last 1 Firefox versions', 7 | 'last 1 Safari versions' 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/users/show.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route' 2 | import query from 'dummy/graph/queries/user' 3 | 4 | export default Route.extend({ 5 | model({id}) { 6 | return this.store.graphQuery({query, variables: {id}}) 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /tests/dummy/mirage/factories/user.js: -------------------------------------------------------------------------------- 1 | import { Factory, faker } from 'ember-cli-mirage' 2 | 3 | export default Factory.extend({ 4 | firstName(i) { 5 | return `Person ${i}` 6 | }, 7 | email() { 8 | return faker.internet.email() 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /tests/dummy/app/graph/mutations/user-create.graphql: -------------------------------------------------------------------------------- 1 | #import '../fragments/user-fragment' 2 | 3 | mutation userCreate($id: ID, $email: String!, $firstName: String!) { 4 | userCreate(id: $id, email: $email, firstName: $firstName) { 5 | ...UserFragment 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /bower_components 2 | /config/ember-try.js 3 | /dist 4 | /tests 5 | /tmp 6 | **/.gitkeep 7 | .bowerrc 8 | .editorconfig 9 | .ember-cli 10 | .gitignore 11 | .jshintrc 12 | .watchmanconfig 13 | .travis.yml 14 | bower.json 15 | ember-cli-build.js 16 | testem.js 17 | -------------------------------------------------------------------------------- /tests/dummy/app/adapters/application.js: -------------------------------------------------------------------------------- 1 | import GraphAdapter from 'ember-graph-data/adapter' 2 | 3 | export default GraphAdapter.extend({ 4 | host: 'http://localhost:4000', 5 | namespace: 'graph', 6 | 7 | headers: { 8 | 'my-header': 'my-header' 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | server: true, 4 | }, 5 | root: true, 6 | parserOptions: { 7 | ecmaVersion: 6, 8 | sourceType: 'module' 9 | }, 10 | extends: 'eslint:recommended', 11 | env: { 12 | browser: true 13 | }, 14 | rules: { 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/users.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route' 2 | import query from 'dummy/graph/queries/users' 3 | 4 | export default Route.extend({ 5 | model() { 6 | let variables = {page: 1, size: 10} 7 | return this.store.graphQuery({query, variables}) 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": true 9 | } 10 | -------------------------------------------------------------------------------- /tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from '../../resolver'; 2 | import config from '../../config/environment'; 3 | 4 | const resolver = Resolver.create(); 5 | 6 | resolver.namespace = { 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix 9 | }; 10 | 11 | export default resolver; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log* 17 | testem.log 18 | -------------------------------------------------------------------------------- /tests/dummy/app/models/user.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data' 2 | 3 | const { 4 | Model, 5 | attr, 6 | hasMany, 7 | belongsTo 8 | } = DS 9 | 10 | export default Model.extend({ 11 | email: attr(), 12 | firstName: attr(), 13 | 14 | posts: hasMany('user/blog-post'), 15 | role: belongsTo('user/role') 16 | }) 17 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/users.hbs: -------------------------------------------------------------------------------- 1 | {{input name='id' value=id}} 2 | {{input name='email' value=email}} 3 | {{input name='firstName' value=firstName}} 4 | 5 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from './config/environment'; 3 | 4 | const Router = Ember.Router.extend({ 5 | location: config.locationType, 6 | rootURL: config.rootURL 7 | }); 8 | 9 | Router.map(function() { 10 | this.route('users', function() { 11 | this.route('show', { path: ':id' }) 12 | }) 13 | }); 14 | 15 | export default Router; 16 | -------------------------------------------------------------------------------- /app/initializers/graph-data.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data' 2 | 3 | export function initialize() { 4 | DS.Store.reopen({ 5 | graphQuery() { 6 | return this.adapterFor('application').query(...arguments) 7 | }, 8 | 9 | graphMutate() { 10 | return this.adapterFor('application').mutate(...arguments) 11 | } 12 | }) 13 | } 14 | 15 | export default { 16 | name: 'graph-data', 17 | initialize 18 | }; 19 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | test_page: 'tests/index.html?hidepassed', 4 | disable_watching: true, 5 | launch_in_ci: [ 6 | 'Chrome' 7 | ], 8 | launch_in_dev: [ 9 | 'Chrome' 10 | ], 11 | browser_args: { 12 | Chrome: [ 13 | '--disable-gpu', 14 | '--headless', 15 | '--remote-debugging-port=9222', 16 | '--window-size=1440,900' 17 | ] 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Resolver from './resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from './config/environment'; 5 | 6 | const App = Ember.Application.extend({ 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix, 9 | Resolver 10 | }); 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.hbs] 17 | insert_final_newline = false 18 | 19 | [*.{diff,md}] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/users.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller' 2 | import mutation from 'dummy/graph/mutations/user-create' 3 | 4 | export default Controller.extend({ 5 | actions: { 6 | userCreate() { 7 | let variables = this.getProperties('id', 'email', 'firstName') 8 | return this.store.graphMutate({mutation, variables}).then( 9 | ({userCreate}) => this.transitionToRoute('users.show', userCreate) 10 | ) 11 | } 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Application from '../../app'; 3 | import config from '../../config/environment'; 4 | 5 | export default function startApp(attrs) { 6 | let attributes = Ember.merge({}, config.APP); 7 | attributes = Ember.merge(attributes, attrs); // use defaults, but you can override; 8 | 9 | return Ember.run(() => { 10 | let application = Application.create(attributes); 11 | application.setupForTesting(); 12 | application.injectTestHelpers(); 13 | return application; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 5 | 6 | module.exports = function(defaults) { 7 | let app = new EmberAddon(defaults, { 8 | // Add options here 9 | }); 10 | 11 | /* 12 | This build file specifies the options for the dummy test app of this 13 | addon, located in `/tests/dummy` 14 | This build file does *not* influence how the addon or the app using it 15 | behave. You most likely want to be modifying `./index.js` or app's build file 16 | */ 17 | 18 | return app.toTree(); 19 | }; 20 | -------------------------------------------------------------------------------- /tests/dummy/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /tests/dummy/mirage/config.js: -------------------------------------------------------------------------------- 1 | const mockData = { 2 | users({schema}) { 3 | return { 4 | users: schema.users.all().models 5 | } 6 | }, 7 | 8 | userCreate({schema, variables}) { 9 | return { 10 | userCreate: schema.users.create(variables) 11 | } 12 | } 13 | } 14 | 15 | export default function() { 16 | this.urlPrefix = 'http://localhost:4000'; 17 | 18 | this.post('graph', (schema, {requestBody}) => { 19 | let query = JSON.parse(requestBody) 20 | let variables = query.variables 21 | 22 | let operationResolver = mockData[query.operationName] 23 | let responseBody = operationResolver ? operationResolver({schema, variables}) : {} 24 | 25 | return {data: responseBody} 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /addon/transport.js: -------------------------------------------------------------------------------- 1 | import {extractFiles} from './utils' 2 | 3 | export const transportJson = function(params) { 4 | return {data: params} 5 | } 6 | 7 | export const transportMultipart = function(params) { 8 | if (typeof FormData === 'undefined') 9 | return transportJson(...arguments) 10 | let variables = params.variables 11 | let files = extractFiles(variables) 12 | let formData = new FormData() 13 | 14 | formData.append('query', params.query) 15 | if(params.operationName) 16 | formData.append('operationName', params.operationName) 17 | files.forEach(({ path, file }) => { 18 | let fullPath = `variables.${path}` 19 | formData.append(fullPath, file) 20 | variables[path] = fullPath 21 | }) 22 | formData.append('variables', JSON.stringify(variables)) 23 | return {body: formData} 24 | } 25 | 26 | export default { 27 | json: transportJson, 28 | multipart: transportMultipart 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Damian Romanów 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /lib/graphql-filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Filter = require('broccoli-filter') 4 | const gql = require('graphql-tag') 5 | 6 | module.exports = class GraphQLFilter extends Filter { 7 | constructor(inputNode, options) { 8 | super(inputNode, options) 9 | this.extensions = ['graphql'] 10 | this.targetExtension = 'js' 11 | } 12 | 13 | processString(source) { 14 | let output = [ 15 | `const doc = ${JSON.stringify(gql([source]), null, 2)};` 16 | ] 17 | output.push('let lines = [];') 18 | 19 | source.split('\n').forEach((line, i) => { 20 | let match = /^#import\s+(.*)/.exec(line) 21 | if (match && match[1]) { 22 | output.push(`import dep${i} from ${match[1]};`) 23 | output.push(`lines.push(dep${i}.string);`) 24 | output.push(`doc.definitions = doc.definitions.concat(dep${i}.definitions);`); 25 | } else { 26 | output.push('lines.push(`' + line + '`);') 27 | } 28 | }) 29 | output.push('doc.string = lines.join(`\n`);') 30 | output.push('export default doc;') 31 | return output.join('\n') 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/acceptance/users-test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach, afterEach } from 'mocha' 2 | import { expect } from 'chai' 3 | import startApp from '../helpers/start-app' 4 | import destroyApp from '../helpers/destroy-app' 5 | 6 | describe('Acceptance | users', function() { 7 | let application 8 | 9 | beforeEach(function() { 10 | application = startApp() 11 | server.createList('user', 10) 12 | }) 13 | 14 | afterEach(function() { 15 | destroyApp(application) 16 | }) 17 | 18 | it('displays users list', function() { 19 | visit('/users') 20 | 21 | andThen(() => { 22 | expect(currentURL()).to.equal('/users') 23 | 24 | expect(find('ul')).to.ok 25 | expect(find('li').length).to.eq(10) 26 | }) 27 | }) 28 | 29 | it('adds new user', function() { 30 | visit('/users') 31 | andThen(() => { 32 | fillIn('input[name=id]', 314) 33 | fillIn('input[name=email]', 'madderdin@hez-hezron.com') 34 | fillIn('input[name=firstName]', 'Mordimer') 35 | 36 | click('button.add-user') 37 | andThen(() => { 38 | expect(currentURL()).to.equal('/users/314') 39 | }) 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | - "4" 5 | - "6" 6 | 7 | sudo: false 8 | 9 | cache: 10 | yarn: true 11 | 12 | sudo: required 13 | dist: trusty 14 | 15 | addons: 16 | apt: 17 | sources: 18 | - google-chrome 19 | packages: 20 | - google-chrome-stable 21 | 22 | env: 23 | # we recommend testing LTS's and latest stable release (bonus points to beta/canary) 24 | - EMBER_TRY_SCENARIO=ember-lts-2.8 25 | - EMBER_TRY_SCENARIO=ember-release 26 | - EMBER_TRY_SCENARIO=ember-beta 27 | - EMBER_TRY_SCENARIO=ember-canary 28 | - EMBER_TRY_SCENARIO=ember-default 29 | 30 | matrix: 31 | fast_finish: true 32 | allow_failures: 33 | - env: EMBER_TRY_SCENARIO=ember-canary 34 | 35 | before_install: 36 | - "export DISPLAY=:99.0" 37 | - "sh -e /etc/init.d/xvfb start" 38 | - yarn install 39 | - export PATH=$HOME/.yarn/bin:$PATH 40 | 41 | install: 42 | - yarn install --no-lockfile --non-interactive 43 | 44 | script: 45 | # Usually, it's ok to finish the test scenario without reverting 46 | # to the addon's original dependency state, skipping "cleanup". 47 | - node_modules/.bin/ember try:one $EMBER_TRY_SCENARIO test --skip-cleanup 48 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "body-footer"}} 31 | {{content-for "test-body-footer"}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict' 3 | 4 | const Webpack = require('broccoli-webpack') 5 | const PackOpts = (name, entry) => { 6 | entry = entry || name 7 | return { 8 | entry: entry, 9 | output: { 10 | filename: `${name}.js`, 11 | library: name, 12 | libraryTarget: 'umd' 13 | } 14 | } 15 | } 16 | 17 | const transformAMD = (name) => ({ 18 | using: [{ transformation: 'amd', as: name }] 19 | }) 20 | 21 | module.exports = { 22 | name: 'ember-graph-data', 23 | 24 | options: { 25 | nodeAssets: { 26 | 'graphql-tag': { 27 | vendor: { 28 | include: ['index.js'], 29 | processTree(input) { 30 | return new Webpack([input], PackOpts('graphql-tag')) 31 | } 32 | } 33 | } 34 | } 35 | }, 36 | 37 | included(app) { 38 | this._super.included.apply(this, arguments) 39 | app.import('vendor/graphql-tag.js', transformAMD('graphql-tag')) 40 | }, 41 | 42 | setupPreprocessorRegistry(type, registry) { 43 | if (type === 'parent') { 44 | registry.add('js', { 45 | name: 'ember-graph-data', 46 | ext: 'graphql', 47 | toTree(tree) { 48 | const GraphQLFilter = require('./lib/graphql-filter') 49 | return new GraphQLFilter(tree) 50 | } 51 | }) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function(environment) { 5 | let ENV = { 6 | modulePrefix: 'dummy', 7 | environment, 8 | rootURL: '/', 9 | locationType: 'auto', 10 | EmberENV: { 11 | FEATURES: { 12 | // Here you can enable experimental features on an ember canary build 13 | // e.g. 'with-controller': true 14 | }, 15 | EXTEND_PROTOTYPES: { 16 | // Prevent Ember Data from overriding Date.parse. 17 | Date: false 18 | } 19 | }, 20 | 21 | APP: { 22 | // Here you can pass flags/options to your application instance 23 | // when it is created 24 | }, 25 | 'ember-graph-data': { 26 | }, 27 | }; 28 | 29 | if (environment === 'development') { 30 | // ENV.APP.LOG_RESOLVER = true; 31 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 32 | // ENV.APP.LOG_TRANSITIONS = true; 33 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 34 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 35 | } 36 | 37 | if (environment === 'test') { 38 | // Testem prefers this... 39 | ENV.locationType = 'none'; 40 | 41 | // keep test console output quieter 42 | ENV.APP.LOG_ACTIVE_GENERATION = false; 43 | ENV.APP.LOG_VIEW_LOOKUPS = false; 44 | 45 | ENV.APP.rootElement = '#ember-testing'; 46 | } 47 | 48 | if (environment === 'production') { 49 | 50 | } 51 | 52 | return ENV; 53 | }; 54 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | useYarn: true, 4 | scenarios: [ 5 | { 6 | name: 'ember-lts-2.8', 7 | bower: { 8 | dependencies: { 9 | 'ember': 'components/ember#lts-2-8' 10 | }, 11 | resolutions: { 12 | 'ember': 'lts-2-8' 13 | } 14 | }, 15 | npm: { 16 | devDependencies: { 17 | 'ember-source': null 18 | } 19 | } 20 | }, 21 | { 22 | name: 'ember-lts-2.12', 23 | npm: { 24 | devDependencies: { 25 | 'ember-source': '~2.12.0' 26 | } 27 | } 28 | }, 29 | { 30 | name: 'ember-release', 31 | bower: { 32 | dependencies: { 33 | 'ember': 'components/ember#release' 34 | }, 35 | resolutions: { 36 | 'ember': 'release' 37 | } 38 | }, 39 | npm: { 40 | devDependencies: { 41 | 'ember-source': null 42 | } 43 | } 44 | }, 45 | { 46 | name: 'ember-beta', 47 | bower: { 48 | dependencies: { 49 | 'ember': 'components/ember#beta' 50 | }, 51 | resolutions: { 52 | 'ember': 'beta' 53 | } 54 | }, 55 | npm: { 56 | devDependencies: { 57 | 'ember-source': null 58 | } 59 | } 60 | }, 61 | { 62 | name: 'ember-canary', 63 | bower: { 64 | dependencies: { 65 | 'ember': 'components/ember#canary' 66 | }, 67 | resolutions: { 68 | 'ember': 'canary' 69 | } 70 | }, 71 | npm: { 72 | devDependencies: { 73 | 'ember-source': null 74 | } 75 | } 76 | }, 77 | { 78 | name: 'ember-default', 79 | npm: { 80 | devDependencies: {} 81 | } 82 | } 83 | ] 84 | }; 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-graph-data", 3 | "version": "0.2.1", 4 | "description": "GraphQL & ember-data integration for ambitious web apps.", 5 | "keywords": [ 6 | "ember-addon", 7 | "ember-data", 8 | "graphql" 9 | ], 10 | "license": "MIT", 11 | "author": "Damian Romanów", 12 | "directories": { 13 | "doc": "doc", 14 | "test": "tests" 15 | }, 16 | "repository": "https://github.com/HarenBroog/ember-graph-data", 17 | "scripts": { 18 | "build": "ember build", 19 | "start": "ember server", 20 | "test": "ember try:each" 21 | }, 22 | "dependencies": { 23 | "broccoli-webpack": "^1.0.0", 24 | "ember-cli-babel": "^6.3.0", 25 | "ember-cli-node-assets": "^0.2.2", 26 | "graphql-tag": "^1.2.4" 27 | }, 28 | "devDependencies": { 29 | "broccoli-asset-rev": "^2.4.5", 30 | "ember-ajax": "^3.0.0", 31 | "ember-cli": "~2.15.1", 32 | "ember-cli-chai": "^0.4.0", 33 | "ember-cli-cors": "^0.0.2", 34 | "ember-cli-dependency-checker": "^2.0.0", 35 | "ember-cli-eslint": "^4.0.0", 36 | "ember-cli-htmlbars": "^2.0.1", 37 | "ember-cli-htmlbars-inline-precompile": "^1.0.0", 38 | "ember-cli-inject-live-reload": "^1.4.1", 39 | "ember-cli-mirage": "^0.4.0", 40 | "ember-cli-mocha": "^0.14.4", 41 | "ember-cli-shims": "^1.1.0", 42 | "ember-cli-sri": "^2.1.0", 43 | "ember-cli-uglify": "^1.2.0", 44 | "ember-data": "^2.17.0", 45 | "ember-disable-prototype-extensions": "^1.1.2", 46 | "ember-export-application-global": "^2.0.0", 47 | "ember-load-initializers": "^1.0.0", 48 | "ember-resolver": "^4.0.0", 49 | "ember-source": "~2.15.0", 50 | "loader.js": "^4.2.3" 51 | }, 52 | "engines": { 53 | "node": "^4.5 || 6.* || >= 7.*" 54 | }, 55 | "ember-addon": { 56 | "configPath": "tests/dummy/config", 57 | "before": [ 58 | "ember-cli-babel" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/unit/build/graphql-filter-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it, context, beforeEach } from 'mocha' 3 | import gql from 'graphql-tag' 4 | // import { setupTest } from 'ember-mocha' 5 | import testFragment from './test-fragment' 6 | import testQuery from './test-query' 7 | 8 | const normalizeString = (str) => str.replace(/\s+/g, ' ').trim() 9 | const compiledFragment = gql` 10 | fragment testFragment on Object { 11 | name 12 | }` 13 | const compiledQuery = gql` 14 | query TestQuery { 15 | subject { 16 | ...testFragment 17 | } 18 | } 19 | 20 | fragment testFragment on Object { 21 | name 22 | }` 23 | 24 | describe('Unit | Build | graphql-filter', function() { 25 | beforeEach(function() { 26 | this.query = testFragment 27 | this.originalQuery = compiledFragment 28 | this.originalString = normalizeString(`fragment testFragment on Object { 29 | name 30 | }`) 31 | }) 32 | 33 | it('compiles AST definitions', function() { 34 | expect(this.query.definitions).to.deep.equal(this.originalQuery.definitions) 35 | }) 36 | 37 | it('compiles string value', function() { 38 | expect(normalizeString(this.query.string)).to.equal(this.originalString) 39 | }) 40 | 41 | context('query with #import references', function() { 42 | beforeEach(function() { 43 | this.query = testQuery 44 | this.originalQuery = compiledQuery 45 | this.originalString = normalizeString(` 46 | fragment testFragment on Object { 47 | name 48 | } 49 | 50 | query TestQuery { 51 | subject { 52 | ...testFragment 53 | } 54 | }` 55 | ) 56 | }) 57 | 58 | it('compiles AST definitions', function() { 59 | expect(normalizeString(this.query.string)).to.equal(this.originalString) 60 | }) 61 | 62 | it('compiles string value', function() { 63 | expect(normalizeString(this.query.string)).to.equal(this.originalString) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /addon/utils.js: -------------------------------------------------------------------------------- 1 | export const mapValues = (object, fun) => { 2 | let result = {} 3 | Object.keys(object).forEach(key => { 4 | result[key] = fun(object[key], key) 5 | }) 6 | return result 7 | } 8 | 9 | export const objectFilter = (object, fun) => { 10 | let result = {} 11 | Object.keys(object).forEach(key => { 12 | let val = object[key] 13 | if(fun(val, key)) 14 | result[key] = object[key] 15 | }) 16 | return result 17 | } 18 | 19 | export const objectReject = (object, fun) => { 20 | return objectFilter( 21 | object, 22 | (val, key) => !fun(val, key) 23 | ) 24 | } 25 | export const isObject = val => typeof val === 'object' && val !== null 26 | 27 | export const extractFiles = (tree, treePath = '') => { 28 | const files = [] 29 | 30 | const recurse = (node, nodePath) => { 31 | // Iterate enumerable properties of the node 32 | Object.keys(node).forEach(key => { 33 | // Skip non-object 34 | if (!isObject(node[key])) return 35 | 36 | const path = `${nodePath}${key}` 37 | 38 | if ( 39 | // Node is a File 40 | (typeof File !== 'undefined' && node[key] instanceof File) 41 | ) { 42 | // Extract the file and it's object tree path 43 | files.push({ path, file: node[key] }) 44 | 45 | // Delete the file. Array items must be deleted without reindexing to 46 | // allow repopulation in a reverse operation. 47 | delete node[key] 48 | 49 | // No further checks or recursion 50 | return 51 | } 52 | 53 | if (typeof FileList !== 'undefined' && node[key] instanceof FileList) 54 | // Convert read-only FileList to an array for manipulation 55 | node[key] = Array.from(node[key]) 56 | 57 | // Recurse into child node 58 | recurse(node[key], `${path}.`) 59 | }) 60 | } 61 | 62 | if (isObject(tree)) 63 | // Recurse object tree 64 | recurse( 65 | tree, 66 | // If a tree path was provided, append a dot 67 | treePath === '' ? treePath : `${treePath}.` 68 | ) 69 | 70 | return files 71 | } 72 | -------------------------------------------------------------------------------- /addon/serializer.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember' 2 | const { 3 | run 4 | } = Ember 5 | import DS from 'ember-data' 6 | import {mapValues, isObject} from './utils' 7 | import {isNone} from '@ember/utils' 8 | import {isArray} from '@ember/array' 9 | import ArrayProxy from '@ember/array/proxy' 10 | import {get} from '@ember/object' 11 | import { 12 | camelize, 13 | underscore 14 | } from '@ember/string' 15 | 16 | export default DS.JSONSerializer.extend({ 17 | isNewSerializerAPI: true, 18 | 19 | normalizeCase(string) { 20 | return camelize(string) 21 | }, 22 | 23 | normalize(payload) { 24 | return run(() => this._normalize(payload)) 25 | }, 26 | 27 | _normalize(payload) { 28 | if(isArray(payload)) return this._normalizeArray(payload) 29 | let modelClass = this.extractModelClass(payload) 30 | if(modelClass) return this._normalizeModel(payload, modelClass) 31 | if(isObject(payload)) return this._normalizeObject(payload) 32 | return payload 33 | }, 34 | 35 | _normalizeArray(array) { 36 | return ArrayProxy.create({ 37 | content: array.map(item => this._normalize(item)) 38 | }) 39 | }, 40 | 41 | _normalizeModel(payload, modelClass = null) { 42 | modelClass = modelClass || this.extractModelClass(payload) 43 | 44 | let resourceHash = { 45 | id: this.extractId(modelClass, payload), 46 | type: modelClass.modelName, 47 | attributes: this.extractAttributes(modelClass, payload), 48 | relationships: this.extractRelationships(modelClass, payload) 49 | } 50 | this.applyTransforms(modelClass, resourceHash.attributes) 51 | return this.get('store').push({data: resourceHash}) 52 | }, 53 | 54 | _normalizeObject(payload) { 55 | return mapValues(payload, (val) => this._normalize(val)) 56 | }, 57 | 58 | modelNameNamespaceSeparator: '--', 59 | modelNameFromGraphResponse(response) { 60 | return response.__typename 61 | }, 62 | 63 | lookupModelClass(name) { 64 | try { 65 | return name ? this.get('store').modelFor(name) : null 66 | } catch(e) { 67 | return null 68 | } 69 | }, 70 | 71 | extractModelClass(resourceHash) { 72 | if(!resourceHash) 73 | return null 74 | let type = this.modelNameFromGraphResponse(resourceHash) 75 | if(type) 76 | type = underscore(type.replace(this.get('modelNameNamespaceSeparator'), '/')) 77 | return this.lookupModelClass(type) 78 | }, 79 | 80 | extractAttributes(modelClass, resourceHash) { 81 | let attributeKey 82 | let attributes = {} 83 | 84 | modelClass.eachAttribute((key) => { 85 | attributeKey = this.keyForAttribute(key, 'deserialize') 86 | if (resourceHash.hasOwnProperty(attributeKey)) { 87 | let val = resourceHash[attributeKey] 88 | 89 | attributes[key] = this._normalize(val) 90 | } 91 | }) 92 | 93 | return attributes 94 | }, 95 | 96 | extractRelationships(modelClass, resourceHash) { 97 | let relationships = {} 98 | modelClass.eachRelationship((key, meta) => { 99 | let data = resourceHash[key] 100 | if (isNone(data)) return 101 | 102 | let normalized = this._normalize(data) 103 | 104 | if(meta.kind === "hasMany") { 105 | relationships[key] = { 106 | data: normalized.map(item => ({ id: get(item, 'id'), type: meta.type })) 107 | } 108 | } else { 109 | relationships[key] = { 110 | data: { id: get(normalized, 'id'), type: meta.type } 111 | } 112 | } 113 | }) 114 | return relationships 115 | }, 116 | }); 117 | -------------------------------------------------------------------------------- /tests/unit/adapters/application-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, context, it, beforeEach, afterEach } from 'mocha' 3 | import { setupTest } from 'ember-mocha' 4 | import { startMirage } from 'dummy/initializers/ember-cli-mirage' 5 | 6 | import ApplicationSerializer from 'dummy/serializers/application' 7 | import mutation from 'dummy/graph/mutations/user-create' 8 | import gql from 'graphql-tag' 9 | 10 | const multipleDefinitionsQuery = gql` 11 | query users($firstName: String!) { 12 | users(firstName: $firstName) { 13 | id 14 | } 15 | } 16 | 17 | query friends($lastName: String!) { 18 | friends(lastName: $lastName) { 19 | id 20 | } 21 | } 22 | ` 23 | describe('Unit | Adapter | application', function() { 24 | setupTest('adapter:application', { 25 | }) 26 | let adapter 27 | 28 | beforeEach(function() { 29 | this.server = startMirage() 30 | this.subject({ 31 | store: { 32 | serializerFor() { 33 | return ApplicationSerializer.create() 34 | } 35 | } 36 | }) 37 | adapter = this.subject() 38 | }) 39 | 40 | afterEach(function() { 41 | this.server.shutdown() 42 | }) 43 | 44 | describe('#query', function() { 45 | it('exists', function() { 46 | expect(this.subject().query).to.be.ok 47 | }) 48 | }) 49 | 50 | describe('#mutate', function() { 51 | it('exists', function() { 52 | expect(this.subject().mutate).to.be.ok 53 | }) 54 | }) 55 | 56 | describe('#request', function() { 57 | it('returns response', function() { 58 | let variables = {email: 'madderdin@hez-hezron.com', firstName: 'M'} 59 | this.subject().mutate({mutation, variables}).then( 60 | ({userCreate}) => { 61 | let result = userCreate 62 | expect(result).to.be.an('object') 63 | expect(result.email).to.be.eq('madderdin@hez-hezron.com') 64 | expect(result.firstName).to.be.eq('M') 65 | } 66 | ) 67 | }) 68 | }) 69 | 70 | describe('graphHelpers', function() { 71 | let variables, 72 | _subject, 73 | testQuery 74 | 75 | beforeEach(function() { 76 | testQuery = mutation 77 | }) 78 | 79 | describe('#normalizeVariables', function() { 80 | beforeEach(function() { 81 | variables = { 82 | notAllowedVar: 1, 83 | email: undefined, 84 | firstName: 'Mordimer', 85 | lastName: 'Madderdin' 86 | } 87 | 88 | _subject = () => adapter.graphHelper('normalizeVariables', variables, testQuery) 89 | }) 90 | 91 | it('filters not allowed variables', function() { 92 | expect(_subject()).not.to.have.key('notAllowedVar') 93 | }) 94 | 95 | it('filters undefined variables', function() { 96 | expect(_subject()).not.to.have.key('email') 97 | 98 | adapter.mutate({mutation, variables}) 99 | }) 100 | 101 | it('passes allowed variables', function() { 102 | expect(_subject()).to.have.key('firstName') 103 | expect(_subject().firstName).to.eq(variables.firstName) 104 | 105 | adapter.mutate({mutation, variables}) 106 | }) 107 | 108 | context('query with multiple definitions', function() { 109 | beforeEach(function() { 110 | testQuery = multipleDefinitionsQuery 111 | }) 112 | 113 | it('passes allowed variables', function() { 114 | expect(_subject()).to.have.keys('firstName', 'lastName') 115 | }) 116 | }) 117 | }) 118 | 119 | describe('#prepareQuery', function() { 120 | beforeEach(function() { 121 | testQuery = { 122 | string: `query users { 123 | users { 124 | id 125 | } 126 | }` 127 | } 128 | _subject = () => adapter.graphHelper('prepareQuery', testQuery).string 129 | }) 130 | 131 | it('appends __typename on all levels', function() { 132 | let occurences = _subject().match(/__typename/g || []).length 133 | expect(occurences).to.eq(2) 134 | }) 135 | 136 | context('graphOptions.addTypename disabled', function() { 137 | beforeEach(function() { 138 | adapter.graphOptions.addTypename = false 139 | }) 140 | 141 | it('does not append any __typename', function() { 142 | expect(_subject()).not.to.contains('__typename') 143 | }) 144 | }) 145 | }) 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /addon/adapter.js: -------------------------------------------------------------------------------- 1 | import DS from 'ember-data' 2 | 3 | import { 4 | get, getProperties, computed 5 | } from '@ember/object' 6 | 7 | import { 8 | isBlank 9 | } from '@ember/utils' 10 | 11 | import { 12 | objectReject 13 | } from './utils' 14 | 15 | import { 16 | assign 17 | } from '@ember/polyfills' 18 | 19 | import { 20 | getOwner 21 | } from '@ember/application' 22 | 23 | import Transport from './transport' 24 | 25 | export default DS.RESTAdapter.extend({ 26 | mergedProperties: ['graphOptions'], 27 | 28 | cachedShoe: computed(function() { 29 | return getOwner(this).lookup('service:cached-shoe') 30 | }), 31 | 32 | graphOptions: { 33 | addTypename: true, 34 | }, 35 | 36 | request(opts) { 37 | let {query, variables} = opts 38 | let originalVariables = variables 39 | let operationName = this.graphHelper('operationName', query) 40 | 41 | query = this.graphHelper('prepareQuery', query) 42 | variables = this.graphHelper('normalizeVariables', originalVariables, query) 43 | 44 | let data = { 45 | variables, 46 | query: query.string, 47 | } 48 | if(operationName) 49 | data.operationName = operationName 50 | 51 | let mergedOpts = assign( 52 | {}, 53 | opts, 54 | {variables, originalVariables} 55 | ) 56 | 57 | return this.ajax( 58 | [this.get('host'), this.get('namespace')].join('/'), 59 | 'POST', 60 | this.requestParams(data, mergedOpts) 61 | ) 62 | .then(r => this.graphHelper('normalizeResponse', r)) 63 | .then(r => this.handleGraphResponse(r, mergedOpts)) 64 | .catch(e => this.handleGraphError(e, mergedOpts)) 65 | }, 66 | 67 | ajaxOptions() { 68 | let opts = this._super(...arguments) 69 | 70 | if(opts.body && opts.body instanceof FormData) { 71 | opts.processData = false 72 | opts.contentType = false 73 | opts.data = opts.body 74 | } 75 | 76 | return opts 77 | }, 78 | 79 | requestParams(data, opts) { 80 | let transportType = null 81 | try { 82 | transportType = opts.options.transport 83 | } catch(e) { 84 | transportType = 'json' 85 | } 86 | return Transport[transportType](...arguments) 87 | }, 88 | 89 | mutate(opts) { 90 | let query = opts.mutation 91 | let variables = opts.variables 92 | 93 | return this.request( 94 | assign({}, opts, {query, variables}) 95 | ) 96 | }, 97 | 98 | query(opts) { 99 | return this.request(opts) 100 | }, 101 | 102 | handleGraphResponse(response) { 103 | return response 104 | }, 105 | 106 | handleGraphError(error) { 107 | return error 108 | }, 109 | 110 | graphHelper(name, ...args) { 111 | return this.graphHelpers[name].call(this, ...args) 112 | }, 113 | 114 | tokenizeAjaxRequest(url, type, options = {}) { 115 | let content = {} 116 | let maybeFormData = (options.body || options.data) 117 | let separator = this.get('separator') 118 | let b2a = this.get('cachedShoe.b2a') 119 | 120 | if(typeof FormData !== 'undefined' && maybeFormData instanceof FormData) { 121 | for (let [k, v] of maybeFormData.entries()) { 122 | content[k] = v 123 | } 124 | content.variables = JSON.parse(content.variables) 125 | } else { 126 | content = maybeFormData 127 | } 128 | 129 | let stringifyObject = (o) => JSON.stringify(o, Object.keys(o).sort()) 130 | let tokenizeString = (s) => b2a.btoa(s.replace(/\\r\\n/g, '\\n').replace(/\s\s+/g, ' ')) 131 | 132 | return tokenizeString( 133 | [url, type, stringifyObject(content)].join(separator) 134 | ) 135 | .replace(/\+/g, '-') 136 | .replace(/\//g, '_') 137 | .replace(/=+$/, '') 138 | }, 139 | 140 | graphHelpers: { 141 | operationName(query) { 142 | try { 143 | return query.definitions[0].name.value 144 | } catch(e) { 145 | return null 146 | } 147 | }, 148 | 149 | prepareQuery(query) { 150 | let preparedQuery = assign({}, query) 151 | if(this.get('graphOptions.addTypename')) preparedQuery.string = preparedQuery.string.replace(/}/g, ` __typename\n}`) 152 | return preparedQuery 153 | }, 154 | 155 | allowedVariables(query) { 156 | return query 157 | .definitions 158 | .map( 159 | def => (def.variableDefinitions || []).map(x => get(x, 'variable.name.value')) 160 | ) 161 | .reduce((vars, acc) => acc.concat(vars), []) 162 | }, 163 | 164 | normalizeVariables(variables, query) { 165 | if(!variables) return {} 166 | let allowedVariables = this.graphHelper('allowedVariables', query) 167 | 168 | return objectReject( 169 | getProperties(variables, allowedVariables), 170 | isBlank 171 | ) 172 | }, 173 | 174 | normalizeResponse(response) { 175 | if(response.errors) { 176 | throw new DS.AdapterError(response.errors) 177 | } else { 178 | return this.get('store').serializerFor('application').normalize(response.data) 179 | } 180 | } 181 | } 182 | }) 183 | -------------------------------------------------------------------------------- /tests/unit/serializers/application-test.js: -------------------------------------------------------------------------------- 1 | import { 2 | get 3 | } from '@ember/object' 4 | import { 5 | dasherize 6 | } from '@ember/string' 7 | 8 | import { expect } from 'chai' 9 | import { describe, context, it, beforeEach, afterEach } from 'mocha' 10 | import { setupTest } from 'ember-mocha' 11 | 12 | import startApp from '../../helpers/start-app' 13 | import destroyApp from '../../helpers/destroy-app' 14 | 15 | const isModel = (obj, name) => { 16 | if(name) 17 | return obj._internalModel.modelName == name 18 | return !!obj._internalModel 19 | } 20 | 21 | const behavesLikePOJO = function() { 22 | it('returns POJO', function() { 23 | expect(this._subject()).to.be.an('object') 24 | expect(isModel(this._subject())).not.to.be.ok 25 | }) 26 | 27 | it('assigns all attributes', function() { 28 | expect(this._subject()).to.have.keys(Object.keys(this.payload())) 29 | }) 30 | 31 | it('lookups models on nested levels', function() { 32 | let result = this._subject() 33 | let probablyUserRole = get(result, 'role') 34 | let probablyUserBlogPosts = get(result, 'posts') 35 | 36 | expect(isModel(probablyUserRole, 'user/role')).to.be.ok 37 | expect(isModel(probablyUserBlogPosts.get('firstObject'), 'user/blog-post')).to.be.ok 38 | }) 39 | } 40 | 41 | describe('Unit | Serializer | application', function() { 42 | setupTest('serializer:application', { 43 | }) 44 | 45 | beforeEach(function() { 46 | this.application = startApp() 47 | this.store = this.application.__container__.lookup('service:store', true) 48 | this.serializer = this.subject() 49 | this.serializer.set('store', this.store) 50 | }) 51 | 52 | afterEach(function() { 53 | destroyApp(this.application) 54 | }) 55 | 56 | describe('#normalize', function() { 57 | beforeEach(function() { 58 | this.payload = function() { 59 | return { 60 | id: '1', 61 | email: 'madderdin@hez-hezron.com', 62 | nonModelAttribute: 'madderdin@hez-hezron.com', 63 | 64 | posts: [ 65 | {id: '1', title: 'A', body: 'aaaa', __typename: 'user/blog-post' }, 66 | {id: '2', title: 'B', body: 'bbbb', __typename: 'user/blog-post' } 67 | ], 68 | 69 | role: { 70 | id: '1', 71 | name: 'inquisitor', 72 | __typename: 'user/role' 73 | }, 74 | 75 | __typename: this.typename 76 | }} 77 | this._subject = function() { return this.serializer.normalize(this.payload()) } 78 | }) 79 | 80 | context('model for __typename exists', function() { 81 | beforeEach(function() { 82 | this.typename = 'user' 83 | }) 84 | 85 | it('returns DS.Model', function() { 86 | expect(this._subject()._internalModel.modelName).to.eq('user') 87 | }) 88 | 89 | it('assigns DS.Model attributes', function() { 90 | expect(this._subject().get('id')).to.eq(this.payload().id) 91 | expect(this._subject().get('email')).to.eq(this.payload().email) 92 | }) 93 | 94 | it('omits attributes not defined in DS.Model', function() { 95 | expect(this._subject().get('nonModelAttribute')).not.to.ok 96 | }) 97 | 98 | it('serializes hasMany relationship', function() { 99 | let relationship = this._subject().get('posts').toArray() 100 | expect(relationship).to.be.an('array') 101 | expect(isModel(relationship[0], 'user/blog-post')).to.be.ok 102 | }) 103 | 104 | it('serializes belongsTo relationship', function() { 105 | let relationship = this._subject().get('role.content') 106 | expect(isModel(relationship, 'user/role')).to.be.ok 107 | }) 108 | }) 109 | 110 | context('model for __typename does not exist', function() { 111 | beforeEach(function() { 112 | this.typename = 'non-existing-model' 113 | }) 114 | 115 | behavesLikePOJO() 116 | }) 117 | 118 | context('__typename missing', function() { 119 | beforeEach(function() { 120 | this.payload = function() { 121 | return { 122 | id: '1', 123 | email: 'madderdin@hez-hezron.com', 124 | nonModelAttribute: 'madderdin@hez-hezron.com', 125 | 126 | posts: [ 127 | {id: '1', title: 'A', body: 'aaaa', __typename: 'user/blog-post' }, 128 | {id: '2', title: 'B', body: 'bbbb', __typename: 'user/blog-post' } 129 | ], 130 | 131 | role: { 132 | id: '1', 133 | name: 'inquisitor', 134 | __typename: 'user/role' 135 | } 136 | } 137 | } 138 | }) 139 | 140 | behavesLikePOJO() 141 | }) 142 | }) 143 | 144 | describe('#extractModelClass', function() { 145 | beforeEach(function() { 146 | this._subject = function() { return this.serializer.extractModelClass(this.payload()) } 147 | this.payload = function() { 148 | return { 149 | __typename: this.typename 150 | } 151 | } 152 | this.typename = 'user/blog-post' 153 | }), 154 | 155 | it('calls #modelNameFromGraphResponse', function() { 156 | let called = false 157 | this.serializer.modelNameFromGraphResponse = function() { 158 | called = true 159 | } 160 | 161 | this._subject() 162 | expect(called).to.be.ok 163 | }) 164 | 165 | it('returns DS.Model class', function() { 166 | expect(this._subject().modelName).to.eq(this.typename) 167 | }) 168 | 169 | context('__typename in PascalCase', function() { 170 | beforeEach(function() { 171 | this.typename = 'AdminUser' 172 | }) 173 | 174 | it('returns DS.Model class', function() { 175 | expect(this._subject().modelName).to.eq(dasherize(this.typename)) 176 | }) 177 | }) 178 | 179 | context('__typename in camelCase', function() { 180 | beforeEach(function() { 181 | this.typename = 'adminUser' 182 | }) 183 | 184 | it('returns DS.Model class', function() { 185 | expect(this._subject().modelName).to.eq(dasherize(this.typename)) 186 | }) 187 | }) 188 | 189 | context('__typename dasherized with double dash', function() { 190 | beforeEach(function() { 191 | this.typename = 'user--blog-post' 192 | }) 193 | 194 | it('returns DS.Model class', function() { 195 | expect(this._subject().modelName).to.eq('user/blog-post') 196 | }) 197 | }) 198 | 199 | context('__typename with custom namespace separator', function() { 200 | beforeEach(function() { 201 | this.typename = 'User$$$BlogPost' 202 | this.serializer.modelNameNamespaceSeparator = '$$$' 203 | }) 204 | 205 | it('returns DS.Model class', function() { 206 | expect(this._subject().modelName).to.eq('user/blog-post') 207 | }) 208 | }) 209 | 210 | it('returns DS.Model class for some strange cases', function() { 211 | [ 212 | `user--blog-post`, 213 | `User--BlogPost`, 214 | `user--Blog-Post`, 215 | `User--blogPost`, 216 | `User--blog_Post`, 217 | `user/blog-post`, 218 | `user/blog_post`, 219 | `User/BlogPost`, 220 | ].forEach((typename) => { 221 | this.typename = typename 222 | expect(this._subject().modelName).to.eq('user/blog-post') 223 | }) 224 | }) 225 | }) 226 | }) 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/dt/ember-graph-data.svg)](https://www.npmjs.com/package/ember-graph-data) [![npm version](https://img.shields.io/npm/v/ember-graph-data.svg)](https://www.npmjs.com/package/ember-graph-data) [![Ember Observer Score](http://emberobserver.com/badges/ember-graph-data.svg)](http://emberobserver.com/addons/ember-graph-data) [![Travis](https://img.shields.io/travis/HarenBroog/ember-graph-data/master.svg)](https://travis-ci.org/HarenBroog/ember-graph-data) [![Code Climate](https://img.shields.io/codeclimate/maintainability/HarenBroog/ember-graph-data.svg)](https://codeclimate.com/github/HarenBroog/ember-graph-data) 2 | 3 | # ember-graph-data 4 | 5 | GraphQL & EmberData integration for ambitious apps! 6 | 7 | 8 | * [Installation](#installation) 9 | * [Minimal setup](#minimal-setup) 10 | * [Usage](#usage) 11 | * [Transport layer](#transport-layer) 12 | * [Adapter](#adapter) 13 | * [headers support](#headers-support) 14 | * [handle error & handle response](#handle-error--handle-response) 15 | * [additional config](#additional-config) 16 | * [Serializer](#serializer) 17 | * [custom modelName mapping](#custom-modelname-mapping) 18 | * [custom modelName namespace separator](#custom-modelname-namespace-separator) 19 | * [Automatic model lookup](#automatic-model-lookup) 20 | * [Fastboot](#fastboot) 21 | * [enhanced rehydration](#enhanced-rehydration) 22 | 23 | 24 | ## Installation 25 | 26 | Ensure you have `ember-data` installed: 27 | 28 | ```bash 29 | ember install ember-data 30 | ``` 31 | 32 | And then: 33 | ```bash 34 | ember install ember-graph-data 35 | ``` 36 | ## Minimal setup 37 | 38 | `app/adapters/application.js` 39 | ```js 40 | import GraphAdapter from 'ember-graph-data/adapter' 41 | 42 | export default GraphAdapter.extend({ 43 | host: 'http://localhost:4000', // your API host 44 | namespace: 'api/v1/graph', // your API namespace 45 | }) 46 | ``` 47 | `app/serializers/application.js` 48 | ```js 49 | import GraphSerializer from 'ember-graph-data/serializer' 50 | 51 | export default GraphSerializer.extend() 52 | ``` 53 | 54 | ## Usage 55 | 56 | `app/routes/posts.js` 57 | ```js 58 | import Route from '@ember/routing/route' 59 | import query from 'my-app/gql/queries/posts' 60 | import mutation from 'my-app/gql/mutations/post-create' 61 | 62 | export default Route.extend({ 63 | model(params) { 64 | let variables = { page: 1 } 65 | return this.store.graphQuery({query, variables}) 66 | } 67 | 68 | actions: { 69 | createPost(variables) { 70 | return this.store.graphMutate({mutation, variables}) 71 | } 72 | } 73 | }) 74 | ``` 75 | 76 | ### Transport layer 77 | 78 | Sometimes `json` is not sufficient in expressing our application needs. File upload is a good example. Of course it can be done by sending them in `base64` form, but it is extremely ineffective (particularly with big files). Or we can prepare special non-graphql endpoint on the server side. None of the above seems to be a good solution. That's why `ember-graph-data` supports sending `graphql` queries and mutations in `json` and `multipart` form. In `multipart` mode, adapter will serialize any file encountered in `variables` as another field in multipart request. 79 | 80 | `app/routes/images.js` 81 | ```js 82 | import Route from '@ember/routing/route' 83 | import query from 'my-app/gql/queries/images' 84 | import mutation from 'my-app/gql/mutations/image-create' 85 | 86 | export default Route.extend({ 87 | model(params) { 88 | let variables = { page: 1 } 89 | return this.store.graphQuery({query, variables}) 90 | } 91 | 92 | actions: { 93 | createImage(file) { 94 | let variables = { file } 95 | let options = { transport: 'multipart' } 96 | return this.store.graphMutate({mutation, variables, options}) 97 | } 98 | } 99 | }) 100 | ``` 101 | On the server side it was tested with: 102 | * [absinthe_plug](https://github.com/absinthe-graphql/absinthe_plug) 103 | 104 | ## Adapter 105 | ### headers support 106 | `app/adapters/application.js` 107 | ```js 108 | import GraphAdapter from 'ember-graph-data/adapter' 109 | import {computed} from '@ember/object' 110 | import {inject as service} from '@ember/service' 111 | 112 | export default GraphAdapter.extend({ 113 | session: service(), 114 | headers: computed('session.jwt', function() { 115 | return { 116 | // authorize reuests 117 | 'Authorization': `Bearer ${this.get('session.jwt')}`, 118 | // maybe provide localized output? 119 | 'Content-Language': 'pl' 120 | // etc 121 | } 122 | }) 123 | }) 124 | ``` 125 | 126 | ### handle error & handle response 127 | `app/adapters/application.js` 128 | ```js 129 | import GraphAdapter from 'ember-graph-data/adapter' 130 | import {inject as service} from '@ember/service' 131 | 132 | export default GraphAdapter.extend({ 133 | eventBus: service(), 134 | 135 | handleGraphError(error, {query, variables}) { 136 | let errors = error.response.errors || [] 137 | if (errors.every((err) => err.code !== 'unauthorized')) return error 138 | // example only. Do whatever you want :) 139 | this.get('eventBus').dispatch({type: 'UNAUTHORIZED'}) 140 | } 141 | 142 | // Hook after successful request 143 | handleGraphResponse(response, {query, variables}) { 144 | return response 145 | }, 146 | } 147 | ``` 148 | 149 | ### additional config 150 | You can configure behaviour of graph adapter. Below options are defaults. 151 | `app/adapters/application.js` 152 | ```js 153 | import GraphAdapter from 'ember-graph-data/adapter' 154 | export default GraphAdapter.extend({ 155 | graphOptions: { 156 | /* appends __typename field to every object in query. 157 | This is used for model lookup. 158 | */ 159 | addTypename: true, 160 | }, 161 | }) 162 | ``` 163 | ## Serializer 164 | ### custom modelName mapping 165 | In case when `__typename` field from API does not directly reflect your DS model names, you can customize it in `modelNameFromGraphResponse`: 166 | `app/serializers/application.js` 167 | ```js 168 | import GraphSerializer from 'ember-graph-data/serializer' 169 | 170 | export default GraphSerializer.extend({ 171 | modelNameFromGraphResponse(response) { 172 | return response.__typename 173 | } 174 | } 175 | ``` 176 | 177 | ### custom modelName namespace separator 178 | Proper handling namespaced DS models requires `__typename` to contain namespace separator. For instance, model `user/blog-post` will be looked-up correctly, for following `__typename` values: 179 | 180 | * `user--blog-post` 181 | * `User--BlogPost` 182 | * `user--Blog-Post` 183 | * `User--blogPost` 184 | * `User--blog_Post` 185 | * `user/blog-post` 186 | * `user/blog_post` 187 | * `User/BlogPost` 188 | 189 | It is not adviced to apply such incoherency in a naming convetion, but still it will be handled. `ember-graph-data` accepts `--` and `/` defaultly as a namespace separator. You can adjust that to your needs like this: 190 | 191 | `app/serializers/application.js` 192 | ```js 193 | import GraphSerializer from 'ember-graph-data/serializer' 194 | 195 | export default GraphSerializer.extend({ 196 | modelNameNamespaceSeparator: '$$$' 197 | } 198 | ``` 199 | 200 | And from now, `user$$$blog-post` and other variations will be recognized correctly. 201 | 202 | ## Automatic model lookup 203 | 204 | `GraphSerializer` automatically lookups and instantiates models for you. This process relies on `__typename` field which is returned from GraphQL server in every object. Lets make some assumptions: 205 | 206 | You have defined following models: 207 | 208 | `app/models/user-role.js` 209 | ```js 210 | import DS from 'ember-data' 211 | 212 | const { 213 | Model, 214 | attr 215 | } = DS 216 | 217 | export default Model.extend({ 218 | name: attr(), 219 | code: attr() 220 | }) 221 | ``` 222 | 223 | `app/models/user.js` 224 | ```js 225 | import DS from 'ember-data' 226 | 227 | const { 228 | Model, 229 | attr, 230 | belongsTo 231 | } = DS 232 | 233 | export default Model.extend({ 234 | firstName: attr(), 235 | lastName: attr(), 236 | email: attr(), 237 | role: belongsTo('user-role', { async: false }) 238 | }); 239 | ``` 240 | 241 | You have sent following query to the GraphQL server: 242 | 243 | `app/graph/queries/users.graphql` 244 | ```graphql 245 | query users { 246 | users { 247 | { 248 | id 249 | firstName 250 | lastName 251 | 252 | role { 253 | id 254 | name 255 | code 256 | } 257 | } 258 | } 259 | } 260 | ``` 261 | 262 | In result of above actions, you will get an array of User models. You can also inspect those models in a `Data` tab of Ember inspector. Moreover, each User will have association `role` properly set. Simple, yet powerful. 263 | 264 | # Fastboot 265 | Fastboot is supported by default. 266 | 267 | ## enhanced rehydration 268 | 269 | Moreover, this addon supports automatic requests caching in [Shoebox](https://github.com/ember-fastboot/ember-cli-fastboot#the-shoebox). Thanks to this, application does not need to refetch already gathered data on the browser side. Mechanics of this process is provided by [ember-cached-shoe](https://github.com/Appchance/ember-cached-shoe). More details to be found in this addon README. 270 | --------------------------------------------------------------------------------