├── .babelrc ├── vue-shims.d.ts ├── docs ├── .vuepress │ ├── styles │ │ └── palette.styl │ └── config.js ├── guide │ ├── headers.md │ ├── README.md │ ├── client.md │ ├── subscriptions.md │ ├── mutations.md │ └── queries.md └── README.md ├── src ├── index.ts ├── cache.ts ├── types.ts ├── Provider.ts ├── utils.ts ├── Mutation.ts ├── Subscription.ts ├── Query.ts └── client.ts ├── .gitignore ├── .prettierrc.js ├── scripts ├── deploy.sh ├── build.js └── config.js ├── .travis.yml ├── test ├── App.vue ├── server │ ├── schema.ts │ └── setup.ts ├── mutation.ts ├── subscription.ts └── query.ts ├── jest.config.js ├── commitlint.config.js ├── .eslintrc.json ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /vue-shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /docs/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | $accentColor = #2A2B4A 2 | $textColor = #2c3e50 3 | $borderColor = #eaecef 4 | $codeBgColor = #282c34 -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createClient } from './client'; 2 | export { Provider, withProvider } from './Provider'; 3 | export { Query } from './Query'; 4 | export { Mutation } from './Mutation'; 5 | export { Subscription } from './Subscription'; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The usual 2 | node_modules 3 | dist 4 | 5 | # For typescript cache 6 | .rpt2_cache 7 | 8 | # For tests 9 | coverage 10 | .nyc_output 11 | 12 | # Editors meta files 13 | .idea 14 | .vscode 15 | .vs 16 | 17 | # logs 18 | 19 | *.log -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // prettier.config.js or .prettierrc.js 2 | module.exports = { 3 | trailingComma: 'none', 4 | printWidth: 120, 5 | tabWidth: 2, 6 | semi: true, 7 | singleQuote: true, 8 | bracketSpacing: true, 9 | arrowParens: 'avoid', 10 | endOfLine: 'lf' 11 | }; -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # abort on errors 4 | set -e 5 | 6 | # build 7 | npm run docs:build 8 | 9 | # navigate into the build output directory 10 | cd docs/.vuepress/dist 11 | 12 | git init 13 | git add -A 14 | git commit -m 'deploy' 15 | 16 | git push -f git@github.com:baianat/vue-gql.git master:gh-pages 17 | cd - 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | before_install: 5 | - curl -o- -L https://yarnpkg.com/install.sh | bash 6 | - export PATH="$HOME/.yarn/bin:$PATH" 7 | install: 8 | - npm install -g codecov 9 | - yarn 10 | after_success: 11 | - yarn test:cover --maxWorkers=$(nproc) 12 | - bash <(curl -s https://codecov.io/bash) 13 | -------------------------------------------------------------------------------- /test/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /test/server/schema.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Post { 3 | id: ID! 4 | title: String! 5 | slug: String! 6 | isLiked: Boolean! 7 | } 8 | 9 | type Query { 10 | posts: [Post]! 11 | 12 | post (id: ID!): Post! 13 | } 14 | 15 | type LikePostMutationResponse { 16 | success: Boolean! 17 | code: String! 18 | message: String! 19 | post: Post 20 | } 21 | 22 | type Mutation { 23 | likePost (id: ID!): LikePostMutationResponse! 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/test/server/setup.ts'], 3 | testMatch: ['**/test/**/*.ts'], 4 | testPathIgnorePatterns: ['/server/', '/dist/'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | '^.+\\.jsx?$': 'babel-jest', 8 | '.*\\.(vue)$': '/node_modules/vue-jest' 9 | }, 10 | moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'vue'], 11 | collectCoverageFrom: ['/src/**/*.{ts,js}'], 12 | coveragePathIgnorePatterns: ['/src.*/index.ts'], 13 | moduleNameMapper: { 14 | '^vue$': 'vue/dist/vue.common.js', 15 | '^@/(.*)$': '/src/$1' 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'body-leading-blank': [1, 'always'], 4 | 'footer-leading-blank': [1, 'always'], 5 | 'header-max-length': [2, 'always', 72], 6 | 'scope-case': [2, 'always', 'lower-case'], 7 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 8 | 'subject-empty': [2, 'never'], 9 | 'subject-full-stop': [2, 'never', '.'], 10 | 'type-case': [2, 'always', 'lower-case'], 11 | 'type-empty': [2, 'never'], 12 | 'type-enum': [ 13 | 2, 14 | 'always', 15 | ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'] 16 | ] 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { OperationResult, Operation } from './types'; 2 | import { getQueryKey } from './utils'; 3 | 4 | interface ResultCache { 5 | [k: string]: OperationResult; 6 | } 7 | 8 | export function makeCache() { 9 | const resultCache: ResultCache = {}; 10 | 11 | function afterQuery(operation: Operation, result: OperationResult) { 12 | const key = getQueryKey(operation); 13 | 14 | resultCache[key] = result; 15 | } 16 | 17 | function getCachedResult(operation: Operation): OperationResult | undefined { 18 | const key = getQueryKey(operation); 19 | 20 | return resultCache[key]; 21 | } 22 | 23 | return { 24 | afterQuery, 25 | getCachedResult 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql'; 2 | 3 | export interface OperationResult { 4 | data: any; 5 | errors: any; 6 | } 7 | 8 | export type CachePolicy = 'cache-and-network' | 'network-only' | 'cache-first'; 9 | 10 | export interface Operation { 11 | query: string | DocumentNode; 12 | variables?: { [k: string]: any }; 13 | } 14 | 15 | export interface ObserverLike { 16 | next: (value: T) => void; 17 | error: (err: any) => void; 18 | complete: () => void; 19 | } 20 | 21 | export interface Unsub { 22 | unsubscribe: () => void; 23 | } 24 | 25 | /** An abstract observable interface conforming to: https://github.com/tc39/proposal-observable */ 26 | export interface ObservableLike { 27 | subscribe(observer: ObserverLike): Unsub; 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "env": { 7 | "browser": true, 8 | "commonjs": true, 9 | "es6": true, 10 | "jest/globals": true 11 | }, 12 | "extends": [ 13 | "standard", 14 | "plugin:jest/recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:prettier/recommended", 17 | "prettier/@typescript-eslint" 18 | ], 19 | "plugins": ["jest", "prettier", "@typescript-eslint"], 20 | "rules": { 21 | "@typescript-eslint/camelcase": "off", 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/explicit-function-return-type": "off", 24 | "@typescript-eslint/no-use-before-define": "off" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const mkdirpNode = require('mkdirp'); 2 | const chalk = require('chalk'); 3 | const { promisify } = require('util'); 4 | const { configs, utils, paths } = require('./config'); 5 | 6 | const mkdirp = promisify(mkdirpNode); 7 | 8 | async function build() { 9 | await mkdirp(paths.dist); 10 | // eslint-disable-next-line 11 | console.log(chalk.cyan('Generating ESM build...')); 12 | await utils.writeBundle(configs.esm, 'vql.esm.js'); 13 | // eslint-disable-next-line 14 | console.log(chalk.cyan('Done!')); 15 | 16 | // eslint-disable-next-line 17 | console.log(chalk.cyan('Generating UMD build...')); 18 | await utils.writeBundle(configs.umd, 'vql.js', true); 19 | // eslint-disable-next-line 20 | console.log(chalk.cyan('Done!')); 21 | } 22 | 23 | build(); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "declaration": true, 8 | "declarationDir": "dist/types", 9 | "sourceMap": true, 10 | "outDir": "dist/lib", 11 | "noImplicitAny": true, 12 | "strict": true, 13 | "strictNullChecks": true, 14 | "strictBindCallApply": true, 15 | "strictFunctionTypes": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true, 18 | "typeRoots": ["node_modules/@types"], 19 | "esModuleInterop": true, 20 | "allowSyntheticDefaultImports": true 21 | }, 22 | "include": ["src", "test"], 23 | "files": ["./vue-shims.d.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | const sidebars = { 2 | guide: ['', 'client', 'queries', 'mutations', 'headers', 'subscriptions'] 3 | }; 4 | 5 | function genSidebarConfig(...names) { 6 | return names.map(t => { 7 | return { 8 | title: t, 9 | collapsable: false, 10 | children: sidebars[t.toLowerCase()] 11 | }; 12 | }); 13 | } 14 | 15 | module.exports = { 16 | base: '/vue-gql/', 17 | title: 'Vue-gql', 18 | description: 'A small and fast GraphQL client for Vue.js', 19 | themeConfig: { 20 | docsDir: 'docs', 21 | repo: 'baianat/vue-gql', 22 | nav: [{ text: 'Home', link: '/' }, { text: 'Guide', link: '/guide/' }], 23 | sidebarDepth: 1, 24 | sidebar: { 25 | '/guide/': genSidebarConfig('Guide') 26 | }, 27 | displayAllHeaders: true // Default: false 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /docs/guide/headers.md: -------------------------------------------------------------------------------- 1 | # Headers 2 | 3 | You often need to add authorization token to your outgoing requests like `Authorization` or localization info like `Content-Language`header. 4 | 5 | You can pass in a `context` function to the `createClient` function. The `context` factory function should return an object that looks like this: 6 | 7 | ```ts 8 | // The same options passed to `fetch` but without a body prop. 9 | interface FetchOptions extends Omit {} 10 | 11 | // The context fn result. 12 | interface GraphQLRequestContext { 13 | fetchOptions?: FetchOptions; 14 | } 15 | 16 | type ContextFactory = () => GraphQLRequestContext; 17 | ``` 18 | 19 | This means you are able to change the headers sent with the requests, for example: 20 | 21 | ```js 22 | import { createClient } from 'vue-gql'; 23 | 24 | const client = createClient({ 25 | endpoint: '/graphql', 26 | context: () => { 27 | return { 28 | fetchOptions: { 29 | headers: { 30 | Authorization: 'bearer TOKEN' 31 | } 32 | } 33 | }; 34 | } 35 | }); 36 | ``` 37 | -------------------------------------------------------------------------------- /src/Provider.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { VqlClient } from './client'; 3 | import { normalizeChildren } from './utils'; 4 | 5 | export const Provider = Vue.extend({ 6 | name: 'VqlProvider', 7 | props: { 8 | client: { 9 | type: VqlClient, 10 | required: true 11 | } 12 | }, 13 | provide() { 14 | return { 15 | $vql: (this as any).client as VqlClient 16 | }; 17 | }, 18 | render(h: any) { 19 | const children = normalizeChildren(this, {}); 20 | if (!children.length) { 21 | return h(); 22 | } 23 | 24 | return children.length <= 1 ? children[0] : h('span', children); 25 | } 26 | }); 27 | 28 | export const withProvider = (component: any, client: VqlClient) => { 29 | const options = 'options' in component ? component.options : component; 30 | 31 | return Vue.extend({ 32 | name: 'withVqlProviderHoC', 33 | functional: true, 34 | render(h: any) { 35 | return h(Provider, { 36 | props: { 37 | client 38 | }, 39 | scopedSlots: { 40 | default: () => h(options) 41 | } 42 | }); 43 | } 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Baianat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/server/setup.ts: -------------------------------------------------------------------------------- 1 | import { mockServer, MockList } from 'graphql-tools'; 2 | import schema from './schema'; 3 | 4 | const server = mockServer(schema, { 5 | Post: () => ({ 6 | id: () => 7 | Math.random() 8 | .toString(36) 9 | .substring(7), 10 | title: () => 'Hello World', 11 | slug: () => 'hello-world' 12 | }), 13 | Query: () => ({ 14 | posts: () => new MockList(5), 15 | post: (_: any, { id }: any) => { 16 | return { 17 | id, 18 | title: `Hello World: ${id}`, 19 | slug: 'hello-world' 20 | }; 21 | } 22 | }), 23 | Mutation: () => ({ 24 | likePost: () => ({ 25 | success: true, 26 | code: '200', 27 | message: 'Operation successful' 28 | }) 29 | }) 30 | }); 31 | 32 | beforeEach(() => { 33 | const fetchController = { 34 | simulateNetworkError: false 35 | }; 36 | 37 | (global as any).fetchController = fetchController; 38 | 39 | // setup a fetch mock 40 | (global as any).fetch = jest.fn(async function mockedAPI(url: string, opts: RequestInit) { 41 | if (fetchController.simulateNetworkError) { 42 | throw new Error('Network Error'); 43 | } 44 | 45 | const body = JSON.parse(opts.body as string); 46 | const res = await server.query(body.query, body.variables); 47 | 48 | return Promise.resolve({ 49 | json() { 50 | return res; 51 | } 52 | }); 53 | }); 54 | 55 | (global as any).sleep = (time: number) => 56 | new Promise(resolve => { 57 | setTimeout(resolve, time); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode, print } from 'graphql'; 2 | import stringify from 'fast-json-stable-stringify'; 3 | import Vue from 'vue'; 4 | import { Operation } from './types'; 5 | 6 | /** 7 | * Normalizes a list of variable objects. 8 | */ 9 | export function normalizeVariables(...variables: object[]) { 10 | let normalized; 11 | const length = variables.length; 12 | for (let i = 0; i < length; i++) { 13 | if (!normalized) { 14 | normalized = {}; 15 | } 16 | 17 | normalized = { ...normalized, ...variables[i] }; 18 | } 19 | 20 | return normalized; 21 | } 22 | 23 | /** 24 | * Normalizes a query string or object to a string. 25 | */ 26 | export function normalizeQuery(query: string | DocumentNode): string | null { 27 | if (typeof query === 'string') { 28 | return query; 29 | } 30 | 31 | if (query.loc) { 32 | return print(query); 33 | } 34 | 35 | return null; 36 | } 37 | 38 | export function hash(x: string) { 39 | let h, i, l; 40 | for (h = 5381 | 0, i = 0, l = x.length | 0; i < l; i++) { 41 | h = (h << 5) + h + x.charCodeAt(i); 42 | } 43 | 44 | return h >>> 0; 45 | } 46 | 47 | export function getQueryKey(operation: Operation) { 48 | const variables = operation.variables ? stringify(operation.variables) : ''; 49 | const query = normalizeQuery(operation.query); 50 | 51 | return hash(`${query}${variables}`); 52 | } 53 | 54 | export function normalizeChildren(context: Vue, slotProps: any) { 55 | if (context.$scopedSlots.default) { 56 | return context.$scopedSlots.default(slotProps) || []; 57 | } 58 | 59 | return context.$slots.default || []; 60 | } 61 | -------------------------------------------------------------------------------- /src/Mutation.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VueConstructor } from 'vue'; 2 | import { VqlClient } from './client'; 3 | import { normalizeChildren } from './utils'; 4 | 5 | type withVqlClient = VueConstructor< 6 | Vue & { 7 | $vql: VqlClient; 8 | } 9 | >; 10 | 11 | function componentData() { 12 | const data: any = null; 13 | const errors: any = null; 14 | 15 | return { 16 | data, 17 | errors, 18 | fetching: false, 19 | done: false 20 | }; 21 | } 22 | 23 | export const Mutation = (Vue as withVqlClient).extend({ 24 | name: 'Mutation', 25 | inject: ['$vql'], 26 | props: { 27 | query: { 28 | type: [String, Object], 29 | required: true 30 | } 31 | }, 32 | data: componentData, 33 | methods: { 34 | async mutate(vars: object = {}) { 35 | if (!this.$vql) { 36 | throw new Error('Could not find the VQL client, did you install the plugin correctly?'); 37 | } 38 | 39 | try { 40 | this.data = null; 41 | this.errors = null; 42 | this.fetching = true; 43 | this.done = false; 44 | const { data, errors } = await this.$vql.executeMutation({ 45 | query: this.query, 46 | variables: vars || undefined 47 | }); 48 | 49 | this.data = data; 50 | this.errors = errors; 51 | this.done = true; 52 | } catch (err) { 53 | this.errors = [err]; 54 | this.data = null; 55 | this.done = false; 56 | } finally { 57 | this.fetching = false; 58 | } 59 | } 60 | }, 61 | render(h) { 62 | const children = normalizeChildren(this, { 63 | data: this.data, 64 | errors: this.errors, 65 | fetching: this.fetching, 66 | done: this.done, 67 | execute: this.mutate 68 | }); 69 | 70 | if (!children.length) { 71 | return h(); 72 | } 73 | 74 | return children.length === 1 ? children[0] : h('span', children); 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vue-gql 3 | lang: en-US 4 | home: true 5 | heroImage: /logo.png 6 | actionText: Get Started → 7 | actionLink: ./guide/ 8 | features: 9 | - title: Declarative 10 | details: Use minimal Vue.js components to work with GraphQL 11 | - title: Fast 12 | details: A lightweight footprint. 13 | - title: Caching 14 | details: Reasonable caching behavior out of the box. 15 | - title: TypeScript 16 | details: Everything is written in TypeScript. 17 | footer: MIT Licensed | Copyright © 2019-present Baianat 18 | description: A small and fast GraphQL client for Vue.js 19 | meta: 20 | - name: og:title 21 | content: Vue-gql 22 | - name: og:description 23 | content: A small and fast GraphQL client for Vue.js 24 | --- 25 | 26 | # Quick Setup 27 | 28 | ## install 29 | 30 | ```bash 31 | # install with yarn 32 | yarn add vue-gql graphql 33 | 34 | # install with npm 35 | npm install vue-gql graphql 36 | ``` 37 | 38 | ## Use 39 | 40 | In your entry file, import the required modules: 41 | 42 | ```js 43 | import Vue from 'vue'; 44 | import { createClient, withProvider } from 'vue-gql'; 45 | import App from './App.vue'; 46 | 47 | const client = createClient({ 48 | url: '/graphql' // Your endpoint 49 | }); 50 | 51 | // use this instead of your App 52 | const AppWithClient = withProvider(App, client); 53 | 54 | new Vue({ 55 | render: h => h(AppWithClient) 56 | }).mount('#app'); 57 | ``` 58 | 59 | Now you can use the `Query` component to run GQL queries: 60 | 61 | ```vue 62 | 72 | 73 | 82 | ``` 83 | -------------------------------------------------------------------------------- /src/Subscription.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VueConstructor } from 'vue'; 2 | import { VqlClient } from './client'; 3 | import { normalizeChildren } from './utils'; 4 | import { Unsub } from './types'; 5 | 6 | function componentData() { 7 | const data: any = null; 8 | const errors: any = null; 9 | 10 | return { 11 | data, 12 | errors, 13 | fetching: false 14 | }; 15 | } 16 | 17 | type withVqlClient = VueConstructor< 18 | Vue & { 19 | _cachedVars?: number; 20 | $vql: VqlClient; 21 | $observer?: Unsub; 22 | } 23 | >; 24 | 25 | export const Subscription = (Vue as withVqlClient).extend({ 26 | name: 'Subscription', 27 | inject: ['$vql'], 28 | props: { 29 | query: { 30 | type: [String, Object], 31 | required: true 32 | }, 33 | variables: { 34 | type: Object, 35 | default: null 36 | }, 37 | pause: { 38 | type: Boolean, 39 | default: false 40 | } 41 | }, 42 | data: componentData, 43 | mounted() { 44 | if (!this.$vql) { 45 | throw new Error('Cannot detect Client Provider'); 46 | } 47 | 48 | const self = this; 49 | this.$observer = this.$vql 50 | .executeSubscription({ 51 | query: this.query, 52 | variables: this.variables 53 | }) 54 | .subscribe({ 55 | next(result) { 56 | self.data = result.data; 57 | self.errors = result.errors; 58 | }, 59 | complete() {}, 60 | error(err) { 61 | self.data = undefined; 62 | self.errors = [err]; 63 | } 64 | }); 65 | }, 66 | beforeDestroy() { 67 | if (this.$observer) { 68 | this.$observer.unsubscribe(); 69 | } 70 | }, 71 | render(h) { 72 | const children = normalizeChildren(this, { 73 | data: this.data, 74 | errors: this.errors, 75 | fetching: this.fetching 76 | }); 77 | 78 | if (!children.length) { 79 | return h(); 80 | } 81 | 82 | return children.length === 1 ? children[0] : h('span', children); 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | lang: en-US 4 | meta: 5 | - name: og:title 6 | content: Introduction | Vue-gql 7 | --- 8 | 9 | # Introduction 10 | 11 | Vue-gql is a minimal [GraphQL](https://graphql.org/) client for Vue.js, exposing components to build highly customizable GraphQL projects. You can use this in small projects or large complex applications. 12 | 13 | We use GraphQL In most of our apps we build at Baianat, but more often than not we end up only using the bare-bones **ApolloLink** without the extra whistles provided by the **ApolloClient**, or we use `fetch` to run our GraphQL queries as we like to handle caching and persisting on our own. Also we would like to use [Vuex](https://vuex.vuejs.org/) in some of the queries, but due to **ApolloClient** having its own immutable store, we cannot use both side-by side and one makes the other redundant. 14 | 15 | To solve this, we needed a bare-bones GraphQL client for Vue.js, but with small quality of life defaults out of the box, like caching. Keeping it simple means it gets to be flexible and lightweight, and can be scaled to handle more complex challenges. 16 | 17 | This library is inspired by [URQL](https://github.com/FormidableLabs/urql). 18 | 19 | ## Features 20 | 21 | - Very small bundle size. 22 | - API is exposed as minimal Vue components that do most of the work for you. 23 | - Query caching by default with sensible configurable policies: `cache-first`, `network-only`, `cache-and-network`. 24 | - SSR support. 25 | - TypeScript friendly as its written in pure TypeScript. 26 | 27 | ## Compatibility 28 | 29 | This library relies on the `fetch` web API to run queries, you can use `unfetch` (client-side) or `node-fetch` (server-side) to use as a polyfill. 30 | 31 | ## Alternatives 32 | 33 | ### [VueApollo](https://github.com/Akryum/vue-apollo) 34 | 35 | **VueApollo** Is probably the most complete Vue GraphQL client out there, like **vue-gql** it exposes components to work with queries and mutations. It builds upon the **ApolloClient** ecosystem. Use it if you find **vue-gql** lacking for your use-case. 36 | -------------------------------------------------------------------------------- /test/mutation.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import flushPromises from 'flush-promises'; 3 | import { Mutation, createClient, Provider } from '../src/index'; 4 | 5 | test('runs mutations', async () => { 6 | const client = createClient({ 7 | url: 'https://test.baianat.com/graphql' 8 | }); 9 | 10 | const wrapper = mount( 11 | { 12 | data: () => ({ 13 | client 14 | }), 15 | components: { 16 | Mutation, 17 | Provider 18 | }, 19 | template: ` 20 |
21 | 22 |
23 | 24 |
25 |

{{ data.likePost.message }}

26 |
27 | 28 |
29 |
30 |
31 |
32 | ` 33 | }, 34 | { sync: false } 35 | ); 36 | 37 | await flushPromises(); 38 | expect(fetch).toHaveBeenCalledTimes(0); 39 | 40 | wrapper.find('button').trigger('click'); 41 | await flushPromises(); 42 | expect(fetch).toHaveBeenCalledTimes(1); 43 | expect(wrapper.find('p').text()).toBe('Operation successful'); 44 | }); 45 | 46 | test('handles errors', async () => { 47 | const client = createClient({ 48 | url: 'https://test.baianat.com/graphql' 49 | }); 50 | 51 | (global as any).fetchController.simulateNetworkError = true; 52 | 53 | const wrapper = mount( 54 | { 55 | data: () => ({ 56 | client 57 | }), 58 | components: { 59 | Mutation, 60 | Provider 61 | }, 62 | template: ` 63 |
64 | 65 |
66 | 67 |
68 |

{{ errors[0].message }}

69 |
70 | 71 |
72 |
73 |
74 |
75 | ` 76 | }, 77 | { sync: false } 78 | ); 79 | 80 | wrapper.find('button').trigger('click'); 81 | await flushPromises(); 82 | expect(wrapper.find('p').text()).toBe('Network Error'); 83 | }); 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-gql 2 | 3 | A small and fast GraphQL client for Vue.js. 4 | 5 |

6 | 7 | [![codecov](https://codecov.io/gh/baianat/vue-gql/branch/master/graph/badge.svg)](https://codecov.io/gh/baianat/vue-gql) 8 | [![Build Status](https://travis-ci.org/baianat/vue-gql.svg?branch=master)](https://travis-ci.org/baianat/vue-gql) 9 | [![Bundle Size](https://badgen.net/bundlephobia/minzip/vue-gql)](https://bundlephobia.com/result?p=vue-gql@0.1.0) 10 | 11 |

12 | 13 | ## Features 14 | 15 | - 📦 **Minimal:** Its all you need to query GQL APIs. 16 | - 🦐 **Tiny:** Very small footprint. 17 | - 🗄 **Caching:** Simple and convenient query caching by default. 18 | - 💪 **TypeScript**: Written in Typescript. 19 | - 💚 Minimal Vue.js Components. 20 | 21 | ## Documentation 22 | 23 | You can find the full [documentation here](https://baianat.github.io/vue-gql) 24 | 25 | ## Quick Start 26 | 27 | First install `vue-gql`: 28 | 29 | ```bash 30 | yarn add vue-gql graphql 31 | 32 | # or npm 33 | 34 | npm install vue-gql graphql --save 35 | ``` 36 | 37 | Setup the GraphQL client/endpoint: 38 | 39 | ```js 40 | import Vue from 'vue'; 41 | import { withProvider, createClient } from 'vue-gql'; 42 | import App from './App.vue'; // Your App Component 43 | 44 | const client = createClient({ 45 | url: 'http://localhost:3002/graphql' 46 | }); 47 | 48 | // Wrap your app component with the provider component. 49 | const AppWithGQL = withProvider(App, client); 50 | 51 | new Vue({ 52 | render: h => h(AppWithGQL) 53 | }).$mount('#app'); 54 | ``` 55 | 56 | Now you can use the `Query` and `Mutation` components to run queries: 57 | 58 | ```vue 59 | 67 | 68 | 77 | ``` 78 | 79 | You can do a lot more, `vue-gql` makes frequent tasks such as re-fetching, caching, mutation responses, error handling, subscriptions a breeze. Consult the documentation for more use-cases and examples. 80 | 81 | ## Compatibility 82 | 83 | This library relies on the `fetch` web API to run queries, you can use [`unfetch`](https://github.com/developit/unfetch) (client-side) or [`node-fetch`](https://www.npmjs.com/package/node-fetch) (server-side) to use as a polyfill. 84 | 85 | ## Examples 86 | 87 | SOON 88 | 89 | ## License 90 | 91 | MIT 92 | -------------------------------------------------------------------------------- /docs/guide/client.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | To start querying GraphQL endpoints, you need to setup a client for that endpoint. **vue-gql** exposes a `createClient` function that allows you to create GraphQL clients for your endpoints. 4 | 5 | ```js 6 | import { createClient } from 'vue-gql'; 7 | 8 | const client = createClient({ 9 | url: '/graphql' // your endpoint. 10 | }); 11 | ``` 12 | 13 | After you've created a client, you need to **provide** the client instance to your app, you can do this via two ways. 14 | 15 | ## Provider component 16 | 17 | **vue-gql** exports a `Provider` component that accepts a single prop, the `client` created by `createClient` function. 18 | 19 | ### SFC 20 | 21 | ```vue 22 | 27 | 28 | 44 | ``` 45 | 46 | ### JSX 47 | 48 | This can be much easier if you are using JSX: 49 | 50 | ```jsx 51 | import { Provider, createClient } from 'vue-gql'; 52 | 53 | const client = createClient({ 54 | url: '/graphql' 55 | }); 56 | 57 | return new Vue({ 58 | el: '#app', 59 | render() { 60 | return ( 61 | 62 | 63 | 64 | ); 65 | } 66 | }); 67 | ``` 68 | 69 | :::tip 70 | The **Provider** component is **renderless** by default, meaning it will not render any extra HTML other than its slot, but only when exactly one child is present, if multiple children exist inside its slot it will render a `span`. 71 | ::: 72 | 73 | ### Multiple Providers 74 | 75 | While uncommon, there is no limitations on how many endpoints you can use within your app, you can use as many provider as you like and that allows you to query different GraphQL APIs within the same app without hassle. 76 | 77 | ```vue 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ``` 86 | 87 | ## withProvider function 88 | 89 | **vue-gql** exposes a `withProvider` function that takes a Vue component and returns the same component wrapped by the `Provider` component, it is very handy to use in JS components and render functions. 90 | 91 | ```js 92 | import Vue from 'vue'; 93 | import { createClient, withProvider } from 'vue-gql'; 94 | import App from './App.vue'; 95 | 96 | const client = createClient({ 97 | url: '/graphql' // Your endpoint 98 | }); 99 | 100 | // use this instead of your App 101 | const AppWithClient = withProvider(App, client); 102 | 103 | new Vue({ 104 | // Render the wrapped version instead. 105 | render: h => h(AppWithClient) 106 | }).mount('#app'); 107 | ``` 108 | 109 | ## Next Steps 110 | 111 | Now that you have successfully setup the GraphQL client, you can use [Query](./queries.md) and [Mutation](./mutations.md) components to execute GraphQL queries. 112 | -------------------------------------------------------------------------------- /src/Query.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VueConstructor } from 'vue'; 2 | import stringify from 'fast-json-stable-stringify'; 3 | import { CachePolicy } from './types'; 4 | import { VqlClient } from './client'; 5 | import { normalizeVariables, normalizeChildren, hash } from './utils'; 6 | 7 | type withVqlClient = VueConstructor< 8 | Vue & { 9 | _cachedVars?: number; 10 | $vql: VqlClient; 11 | } 12 | >; 13 | 14 | function componentData() { 15 | const data: any = null; 16 | const errors: any = null; 17 | 18 | return { 19 | data, 20 | errors, 21 | fetching: false, 22 | done: false 23 | }; 24 | } 25 | 26 | export const Query = (Vue as withVqlClient).extend({ 27 | name: 'Query', 28 | inject: ['$vql'], 29 | props: { 30 | query: { 31 | type: [String, Object], 32 | required: true 33 | }, 34 | variables: { 35 | type: Object, 36 | default: null 37 | }, 38 | cachePolicy: { 39 | type: String, 40 | default: undefined, 41 | validator(value) { 42 | const isValid = [undefined, 'cache-and-network', 'network-only', 'cache-first'].indexOf(value) !== -1; 43 | 44 | return isValid; 45 | } 46 | }, 47 | pause: { 48 | type: Boolean, 49 | default: false 50 | } 51 | }, 52 | data: componentData, 53 | serverPrefetch() { 54 | // fetch it on the server-side. 55 | return (this as any).fetch(); 56 | }, 57 | watch: { 58 | variables: { 59 | deep: true, 60 | handler(value) { 61 | if (this.pause) { 62 | return; 63 | } 64 | 65 | const id = hash(stringify(value)); 66 | if (id === this._cachedVars) { 67 | return; 68 | } 69 | 70 | this._cachedVars = id; 71 | // tslint:disable-next-line: no-floating-promises 72 | this.fetch(); 73 | } 74 | } 75 | }, 76 | methods: { 77 | async fetch(vars?: object, cachePolicy?: CachePolicy) { 78 | if (!this.$vql) { 79 | throw new Error('Could not detect Client Provider'); 80 | } 81 | 82 | try { 83 | this.fetching = true; 84 | const { data, errors } = await this.$vql.executeQuery({ 85 | query: this.query, 86 | variables: normalizeVariables(this.variables, vars || {}), 87 | cachePolicy: cachePolicy || (this.cachePolicy as CachePolicy) 88 | }); 89 | 90 | this.data = data; 91 | this.errors = errors; 92 | } catch (err) { 93 | this.errors = [err]; 94 | this.data = null; 95 | } finally { 96 | this.done = true; 97 | this.fetching = false; 98 | } 99 | } 100 | }, 101 | mounted() { 102 | // fetch it on client side if it was not already. 103 | if (!this.data) { 104 | // tslint:disable-next-line: no-floating-promises 105 | this.fetch(); 106 | } 107 | }, 108 | render(h) { 109 | const children = normalizeChildren(this, { 110 | data: this.data, 111 | errors: this.errors, 112 | fetching: this.fetching, 113 | done: this.done, 114 | execute: ({ cachePolicy }: { cachePolicy?: CachePolicy } = {}) => this.fetch({}, cachePolicy) 115 | }); 116 | 117 | if (!children.length) { 118 | return h(); 119 | } 120 | 121 | return children.length === 1 ? children[0] : h('span', children); 122 | } 123 | }); 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-gql", 3 | "version": "0.2.3", 4 | "description": "A small and fast GraphQL client for Vue.js", 5 | "module": "dist/vql.esm.js", 6 | "unpkg": "dist/vql.js", 7 | "main": "dist/vql.js", 8 | "types": "dist/types/src", 9 | "scripts": { 10 | "docs:dev": "vuepress dev docs", 11 | "docs:build": "vuepress build docs", 12 | "docs:deploy": "./scripts/deploy.sh", 13 | "test": "jest", 14 | "test:cover": "jest --coverage", 15 | "lint": "eslint . '**/*.{js,jsx,ts,tsx}' --fix", 16 | "build": "node scripts/build.js && npm run ts:defs", 17 | "ts:defs": "tsc --emitDeclarationOnly" 18 | }, 19 | "author": "Abdelrahman Awad ", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@babel/core": "^7.5.4", 23 | "@babel/plugin-transform-runtime": "^7.5.0", 24 | "@babel/preset-env": "^7.5.4", 25 | "@commitlint/cli": "^8.0.0", 26 | "@types/fast-json-stable-stringify": "^2.0.0", 27 | "@types/graphql": "^14.2.2", 28 | "@types/jest": "^24.0.15", 29 | "@typescript-eslint/eslint-plugin": "^1.12.0", 30 | "@typescript-eslint/parser": "^1.12.0", 31 | "@vue/test-utils": "^1.0.0-beta.29", 32 | "babel-core": "^7.0.0-bridge.0", 33 | "babel-jest": "^24.8.0", 34 | "bundlesize": "^0.18.0", 35 | "chalk": "^2.4.2", 36 | "eslint": "^6.0.1", 37 | "eslint-config-prettier": "^6.0.0", 38 | "eslint-config-standard": "^13.0.1", 39 | "eslint-plugin-import": "^2.18.0", 40 | "eslint-plugin-jest": "^22.7.2", 41 | "eslint-plugin-node": "^9.1.0", 42 | "eslint-plugin-prettier": "^3.1.0", 43 | "eslint-plugin-promise": "^4.2.1", 44 | "eslint-plugin-standard": "^4.0.0", 45 | "filesize": "^4.1.2", 46 | "flush-promises": "^1.0.2", 47 | "graphql": "^14.4.2", 48 | "graphql-tools": "^4.0.5", 49 | "gzip-size": "^5.1.1", 50 | "husky": "^3.0.0", 51 | "jest": "^24.8.0", 52 | "lint-staged": "^9.2.0", 53 | "mkdirp": "^0.5.1", 54 | "node-fetch": "^2.6.0", 55 | "prettier": "^1.18.2", 56 | "rollup": "^1.16.7", 57 | "rollup-plugin-commonjs": "^10.0.1", 58 | "rollup-plugin-node-resolve": "^5.2.0", 59 | "rollup-plugin-replace": "^2.2.0", 60 | "rollup-plugin-typescript2": "^0.22.0", 61 | "ts-jest": "^24.0.2", 62 | "typescript": "^3.5.3", 63 | "uglify-js": "^3.6.0", 64 | "vue": "^2.6.10", 65 | "vue-jest": "^3.0.4", 66 | "vue-template-compiler": "^2.6.10", 67 | "vuepress": "^1.0.2" 68 | }, 69 | "husky": { 70 | "hooks": { 71 | "pre-commit": "lint-staged", 72 | "commit-msg": "commitlint --edit -E HUSKY_GIT_PARAMS" 73 | } 74 | }, 75 | "files": [ 76 | "dist/*.js", 77 | "dist/locale/*.js", 78 | "dist/types/**/*.d.ts" 79 | ], 80 | "bundlesize": [ 81 | { 82 | "path": "./dist/*.min.js", 83 | "maxSize": "10 kB" 84 | } 85 | ], 86 | "eslintIgnore": [ 87 | "locale", 88 | "dist", 89 | "scripts" 90 | ], 91 | "lint-staged": { 92 | "*.ts": [ 93 | "eslint --fix", 94 | "prettier --write", 95 | "git add", 96 | "jest --maxWorkers=1 --bail --findRelatedTests" 97 | ], 98 | "*.js": [ 99 | "git add" 100 | ] 101 | }, 102 | "dependencies": { 103 | "fast-json-stable-stringify": "^2.0.0" 104 | }, 105 | "peerDependencies": { 106 | "vue": "^2.5.18", 107 | "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const { rollup } = require('rollup'); 4 | const chalk = require('chalk'); 5 | const uglify = require('uglify-js'); 6 | const gzipSize = require('gzip-size'); 7 | const filesize = require('filesize'); 8 | const typescript = require('rollup-plugin-typescript2'); 9 | const resolve = require('rollup-plugin-node-resolve'); 10 | const commonjs = require('rollup-plugin-commonjs'); 11 | const replace = require('rollup-plugin-replace'); 12 | const version = process.env.VERSION || require('../package.json').version; 13 | 14 | const commons = { 15 | banner: `/** 16 | * vql v${version} 17 | * (c) ${new Date().getFullYear()} Baianat 18 | * @license MIT 19 | */`, 20 | outputFolder: path.join(__dirname, '..', 'dist'), 21 | uglifyOptions: { 22 | compress: true, 23 | mangle: true 24 | } 25 | }; 26 | 27 | const paths = { 28 | dist: commons.outputFolder 29 | }; 30 | 31 | const utils = { 32 | stats({ path, code }) { 33 | const { size } = fs.statSync(path); 34 | const gzipped = gzipSize.sync(code); 35 | 36 | return `| Size: ${filesize(size)} | Gzip: ${filesize(gzipped)}`; 37 | }, 38 | async writeBundle({ input, output }, fileName, minify = false) { 39 | const bundle = await rollup(input); 40 | const { 41 | output: [{ code }] 42 | } = await bundle.generate(output); 43 | 44 | let outputPath = path.join(paths.dist, fileName); 45 | fs.writeFileSync(outputPath, code); 46 | let stats = this.stats({ code, path: outputPath }); 47 | // eslint-disable-next-line 48 | console.log(`${chalk.green('Output File:')} ${fileName} ${stats}`); 49 | 50 | if (minify) { 51 | let minifiedFileName = fileName.replace('.js', '') + '.min.js'; 52 | outputPath = path.join(paths.dist, minifiedFileName); 53 | fs.writeFileSync(outputPath, uglify.minify(code, commons.uglifyOptions).code); 54 | stats = this.stats({ code, path: outputPath }); 55 | // eslint-disable-next-line 56 | console.log(`${chalk.green('Output File:')} ${minifiedFileName} ${stats}`); 57 | } 58 | 59 | return true; 60 | } 61 | }; 62 | 63 | const builds = { 64 | umd: { 65 | input: 'src/index.ts', 66 | format: 'umd', 67 | name: 'VueGql', 68 | env: 'production' 69 | }, 70 | esm: { 71 | input: 'src/index.ts', 72 | format: 'es' 73 | } 74 | }; 75 | 76 | function genConfig(options) { 77 | const config = { 78 | input: { 79 | input: options.input, 80 | external: ['vue', 'fast-json-stable-stringify', 'graphql'], 81 | plugins: [ 82 | typescript({ useTsconfigDeclarationDir: true }), 83 | replace({ __VERSION__: version }), 84 | resolve(), 85 | commonjs() 86 | ] 87 | }, 88 | output: { 89 | banner: commons.banner, 90 | format: options.format, 91 | name: options.name, 92 | globals: { 93 | vue: 'Vue' 94 | } 95 | } 96 | }; 97 | 98 | if (options.env) { 99 | config.input.plugins.unshift( 100 | replace({ 101 | 'process.env.NODE_ENV': JSON.stringify(options.env) 102 | }) 103 | ); 104 | } 105 | 106 | return config; 107 | } 108 | 109 | const configs = Object.keys(builds).reduce((prev, key) => { 110 | prev[key] = genConfig(builds[key]); 111 | 112 | return prev; 113 | }, {}); 114 | 115 | module.exports = { 116 | configs, 117 | utils, 118 | uglifyOptions: commons.uglifyOptions, 119 | paths 120 | }; 121 | -------------------------------------------------------------------------------- /docs/guide/subscriptions.md: -------------------------------------------------------------------------------- 1 | # Subscriptions 2 | 3 | `vue-gql` handles subscriptions with the `Subscription` component in the same way as the `Query` component. 4 | 5 | To add support for subscriptions you need to pass a `subscriptionForwarder` function to the `createClient` function, which in turn will call your subscription client. The `subscriptionForwarder` expects an object that follows the [observable spec](https://github.com/tc39/proposal-observable) to be returned. 6 | 7 | The following example uses `apollo-server` with the `subscriptions-transport-ws` package: 8 | 9 | ```js 10 | import { createClient } from 'vue-gql'; 11 | import { SubscriptionClient } from 'subscriptions-transport-ws'; 12 | 13 | const subscriptionClient = new SubscriptionClient('ws://localhost:4001/graphql', {}); 14 | 15 | const client = createClient({ 16 | url: 'http://localhost:4000/graphql', 17 | subscriptionForwarder: op => subscriptionClient.request(op) 18 | }); 19 | ``` 20 | 21 | Once you've setup the `subscriptionForwarder` function, you can now use the `Subscription` component in the same way as the `Query` component. 22 | 23 | The `Subscription` component exposes `data`, `error` on the slot props. 24 | 25 | ```vue{2,4,8,12} 26 | 31 | 32 | 52 | ``` 53 | 54 | The `data` prop will be updated whenever a new value is received from the subscription. 55 | 56 | ## Using Subscriptions 57 | 58 | Having a subscription component printing the data probably isn't that useful, for example in a chat app you would append new messages to the old ones to do that you need refactor your code to do the following: 59 | 60 | - Have the `Subscription` component pass the `data` to a child component `Chatbox`. 61 | - The `Chatbox` component would watch the `data` received and append them to existing messages. 62 | 63 | Here is a minimal example: 64 | 65 | **Chatbox.vue** 66 | 67 | ```vue 68 | 73 | 74 | 88 | ``` 89 | 90 | And in the parent component: 91 | 92 | ```vue 93 | 98 | 99 | 121 | ``` 122 | -------------------------------------------------------------------------------- /docs/guide/mutations.md: -------------------------------------------------------------------------------- 1 | # Mutations 2 | 3 | **vue-gql** exposes a **Mutation** component that is very similar to the **[Query](./queries.md)** component but with few distinct differences: 4 | 5 | - The mutation component **does not** have a `variables` prop. 6 | - The mutation component **does not** run automatically, you have to explicitly call `execute`. 7 | - Cache policies do not apply to mutations as mutations represent real-time actions and will always use `network-only` policy. 8 | 9 | :::tip 10 | The **Mutation** component is **renderless** by default, meaning it will not render any extra HTML other than its slot, but only when exactly one child is present, if multiple children exist inside its slot it will render a `span`. 11 | ::: 12 | 13 | ```vue{3,4,5,6,10,11} 14 | 27 | 28 | 37 | ``` 38 | 39 | ## Passing Variables 40 | 41 | Since the **Mutation** component does not accept `variables` you can pass them to the `execute` method instead: 42 | 43 | ```vue{3,8} 44 | 48 |
49 |

{{ data.likePost.message }}

50 |
51 | 52 |
53 | ``` 54 | 55 | Usually you would wrap your `forms` with the **Mutation** component and handle submits by executing the mutation. 56 | 57 | ## Slot Props 58 | 59 | ### fetching 60 | 61 | The **Mutation** slot props contain more useful information that you can use to build better experience for your users, for example you can use the `fetching` slot prop to display a loading indicator while the form submits. 62 | 63 | ```vue{3,5} 64 | 68 | 69 | 70 |
71 |

{{ data.likePost.message }}

72 |
73 | 74 | 75 |
76 | ``` 77 | 78 | ### done 79 | 80 | The `done` slot prop is a boolean that indicates that the query has been completed. 81 | 82 | ### errors 83 | 84 | The `errors` slot prop contains all errors encountered when running the query. 85 | 86 | ```vue{3,6} 87 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ``` 98 | 99 | ### execute 100 | 101 | Like you previously saw, the `execute` slot prop is a function that executes the mutation, it accepts the variables object if specified, and unlike the same slot prop in the **Query** component it does not affect caching. 102 | 103 | ```vue{3,9} 104 | 108 |
109 |
    110 |
  • {{ post.title }}
  • 111 |
112 | 113 |
114 |
115 | ``` 116 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { makeCache } from './cache'; 2 | import { OperationResult, CachePolicy, Operation, ObservableLike } from './types'; 3 | import { normalizeQuery } from './utils'; 4 | 5 | type Fetcher = typeof fetch; 6 | 7 | type FetchOptions = Omit; 8 | 9 | interface CachedOperation extends Operation { 10 | cachePolicy?: CachePolicy; 11 | } 12 | 13 | interface GraphQLRequestContext { 14 | fetchOptions?: FetchOptions; 15 | } 16 | 17 | type ContextFactory = () => GraphQLRequestContext; 18 | 19 | type SubscriptionForwarder = (operation: Operation) => ObservableLike; 20 | 21 | interface VqlClientOptions { 22 | url: string; 23 | fetch?: Fetcher; 24 | context?: ContextFactory; 25 | cachePolicy?: CachePolicy; 26 | subscriptionForwarder?: SubscriptionForwarder; 27 | } 28 | 29 | function resolveGlobalFetch(): Fetcher | undefined { 30 | if (typeof window !== 'undefined' && 'fetch' in window) { 31 | return window.fetch.bind(window); 32 | } 33 | 34 | if (typeof global !== 'undefined' && 'fetch' in global) { 35 | return (global as any).fetch; 36 | } 37 | 38 | return undefined; 39 | } 40 | 41 | function makeFetchOptions({ query, variables }: Operation, opts: FetchOptions) { 42 | const normalizedQuery = normalizeQuery(query); 43 | if (!normalizedQuery) { 44 | throw new Error('A query must be provided.'); 45 | } 46 | 47 | return { 48 | method: 'POST', 49 | body: JSON.stringify({ query: normalizedQuery, variables }), 50 | ...opts, 51 | headers: { 52 | 'content-type': 'application/json', 53 | ...opts.headers 54 | } 55 | }; 56 | } 57 | 58 | interface VqlClientOptionsWithFetcher extends VqlClientOptions { 59 | fetch: Fetcher; 60 | } 61 | 62 | export class VqlClient { 63 | private url: string; 64 | 65 | private fetch: Fetcher; 66 | 67 | private defaultCachePolicy: CachePolicy; 68 | 69 | private context?: ContextFactory; 70 | 71 | private cache = makeCache(); 72 | 73 | private subscriptionForwarder?: SubscriptionForwarder; 74 | 75 | public constructor(opts: VqlClientOptionsWithFetcher) { 76 | this.url = opts.url; 77 | this.fetch = opts.fetch; 78 | this.context = opts.context; 79 | this.defaultCachePolicy = opts.cachePolicy || 'cache-first'; 80 | this.subscriptionForwarder = opts.subscriptionForwarder; 81 | } 82 | 83 | public async executeQuery(operation: CachedOperation): Promise { 84 | const fetchOptions = this.context ? this.context().fetchOptions : {}; 85 | const opts = makeFetchOptions(operation, fetchOptions || {}); 86 | const policy = operation.cachePolicy || this.defaultCachePolicy; 87 | const cachedResult = this.cache.getCachedResult(operation); 88 | if (policy === 'cache-first' && cachedResult) { 89 | return cachedResult; 90 | } 91 | 92 | const lazyFetch = () => 93 | this.fetch(this.url, opts) 94 | .then(response => response.json()) 95 | .then(result => { 96 | if (policy !== 'network-only') { 97 | this.cache.afterQuery(operation, result); 98 | } 99 | 100 | return result; 101 | }); 102 | 103 | if (policy === 'cache-and-network' && cachedResult) { 104 | lazyFetch(); 105 | 106 | return cachedResult; 107 | } 108 | 109 | return lazyFetch(); 110 | } 111 | 112 | public async executeMutation(operation: Operation): Promise { 113 | const fetchOptions = this.context ? this.context().fetchOptions : {}; 114 | const opts = makeFetchOptions(operation, fetchOptions || {}); 115 | 116 | return this.fetch(this.url, opts).then(response => response.json()); 117 | } 118 | 119 | public executeSubscription(operation: Operation) { 120 | if (!this.subscriptionForwarder) { 121 | throw new Error('No subscription forwarder was set.'); 122 | } 123 | 124 | return this.subscriptionForwarder(operation); 125 | } 126 | } 127 | 128 | export function createClient(opts: VqlClientOptions) { 129 | opts.fetch = opts.fetch || resolveGlobalFetch(); 130 | if (!opts.fetch) { 131 | throw new Error('Could not resolve a fetch() method, you should provide one.'); 132 | } 133 | 134 | return new VqlClient(opts as VqlClientOptionsWithFetcher); 135 | } 136 | -------------------------------------------------------------------------------- /test/subscription.ts: -------------------------------------------------------------------------------- 1 | import { mount, createLocalVue } from '@vue/test-utils'; 2 | import flushPromises from 'flush-promises'; 3 | import { Subscription, createClient, Provider } from '../src/index'; 4 | 5 | const Vue = createLocalVue(); 6 | Vue.component('Subscription', Subscription); 7 | 8 | function makeObservable(throws = false) { 9 | let interval: any; 10 | let counter = 0; 11 | const observable = { 12 | subscribe: function({ next, error }: { error: Function; next: Function }) { 13 | interval = setInterval(() => { 14 | if (throws) { 15 | error(new Error('oops!')); 16 | return; 17 | } 18 | 19 | next({ data: { message: 'New message', id: counter++ } }); 20 | }, 100); 21 | 22 | afterAll(() => { 23 | clearTimeout(interval); 24 | }); 25 | 26 | return { 27 | unsubscribe() { 28 | clearTimeout(interval); 29 | } 30 | }; 31 | } 32 | }; 33 | 34 | return observable; 35 | } 36 | 37 | test('Handles subscriptions', async () => { 38 | const client = createClient({ 39 | url: 'https://test.baianat.com/graphql', 40 | subscriptionForwarder: () => { 41 | return makeObservable(); 42 | } 43 | }); 44 | 45 | const wrapper = mount( 46 | { 47 | data: () => ({ 48 | client 49 | }), 50 | components: { 51 | Subscription, 52 | Provider, 53 | Child: { 54 | props: ['newMessages'], 55 | data: () => ({ messages: [] }), 56 | watch: { 57 | newMessages(this: any, message: object) { 58 | this.messages.push(message); 59 | } 60 | }, 61 | template: ` 62 |
    63 |
  • {{ msg.id }}
  • 64 |
65 | ` 66 | } 67 | }, 68 | template: ` 69 | 70 | 71 | 72 | 73 | 74 | ` 75 | }, 76 | { sync: false } 77 | ); 78 | 79 | await (global as any).sleep(510); 80 | await flushPromises(); 81 | expect(wrapper.findAll('li')).toHaveLength(5); 82 | wrapper.destroy(); 83 | }); 84 | 85 | test('Handles observer errors', async () => { 86 | const client = createClient({ 87 | url: 'https://test.baianat.com/graphql', 88 | subscriptionForwarder: () => { 89 | return makeObservable(true); 90 | } 91 | }); 92 | 93 | const wrapper = mount( 94 | { 95 | data: () => ({ 96 | client 97 | }), 98 | components: { 99 | Subscription, 100 | Provider 101 | }, 102 | template: ` 103 |
104 | 105 | 106 |

{{ errors[0].message }}

107 |
108 |
109 |
110 | ` 111 | }, 112 | { sync: false } 113 | ); 114 | 115 | await (global as any).sleep(150); 116 | await flushPromises(); 117 | expect(wrapper.find('p').text()).toBe('oops!'); 118 | wrapper.destroy(); 119 | }); 120 | 121 | test('renders a span if multiple root is found', async () => { 122 | const client = createClient({ 123 | url: 'https://test.baianat.com/graphql', 124 | subscriptionForwarder: () => { 125 | return makeObservable(true); 126 | } 127 | }); 128 | 129 | const wrapper = mount( 130 | { 131 | data: () => ({ 132 | client 133 | }), 134 | components: { 135 | Subscription, 136 | Provider 137 | }, 138 | template: ` 139 | 140 | 141 | {{ data }} 142 | {{ data }} 143 | 144 | 145 | ` 146 | }, 147 | { sync: false } 148 | ); 149 | 150 | await flushPromises(); 151 | expect(wrapper.findAll('span')).toHaveLength(3); 152 | wrapper.destroy(); 153 | }); 154 | 155 | test('Fails if provider was not resolved', async () => { 156 | expect(() => { 157 | mount( 158 | { 159 | components: { 160 | Subscription 161 | }, 162 | template: ` 163 | 164 | {{ data }} 165 | 166 | ` 167 | }, 168 | { sync: false } 169 | ); 170 | }).toThrow(/Client Provider/); 171 | }); 172 | -------------------------------------------------------------------------------- /docs/guide/queries.md: -------------------------------------------------------------------------------- 1 | # Queries 2 | 3 | You can query GraphQL APIs with the **Query** component after you've setup the [GraphQL Client](./client.md). 4 | 5 | The **Query** component uses slots and [scoped slots](https://vuejs.org/v2/guide/components-slots.html#Scoped-Slots) to provide the query state to the slot template. 6 | 7 | To run a query, the **Query** component takes a required `query` prop that can be either a `string` containing the query or a `DocumentNode` loaded by `graphql-tag/loader` from `.graphql` files. 8 | 9 | :::tip 10 | The **Query** component is **renderless** by default, meaning it will not render any extra HTML other than its slot, but only when exactly one child is present, if multiple children exist inside its slot it will render a `span`. 11 | ::: 12 | 13 | ```vue 14 | 23 | 24 | 33 | ``` 34 | 35 | By default the query will run on the server-side if applicable (via `serverPrefetch`) or on mounted (client-side) if it didn't already. 36 | 37 | :::tip 38 | The examples from now on will omit much of the boilerplate and will only use the `Query` component to demonstrate its uses clearly. 39 | ::: 40 | 41 | ## [graphql-tag](https://github.com/apollographql/graphql-tag) 42 | 43 | You can use `graphql-tag` to compile your queries or load them with the `graphql-tag/loader`. 44 | 45 | ``` 46 | // in script 47 | const todos = gql` 48 | todos { 49 | id 50 | text 51 | } 52 | `; 53 | 54 | // in template 55 | 56 |
57 |

{{ todo.text }}

58 |
59 |
60 | ``` 61 | 62 | Here we are using `require` with the `graphql-tag/loader`: 63 | 64 | ```vue{1} 65 | 66 |
67 |

{{ todo.text }}

68 |
69 |
70 | ``` 71 | 72 | ## Variables 73 | 74 | You can provide variables to your queries using the `variables` optional prop, which is an object containing the variables you would normally send to a GraphQL request. 75 | 76 | ```vue{2} 77 | 84 | 85 | 101 | ``` 102 | 103 | ## Slot Props 104 | 105 | ### fetching 106 | 107 | The **Query** slot props contain more useful information that you can use to build better experience for your users, for example you can use the `fetching` slot prop to display a loading indicator. 108 | 109 | ```vue{1,3} 110 | 111 | 112 | 113 | 114 |
115 |

{{ todo.text }}

116 |
117 | 118 |
119 | ``` 120 | 121 | ### done 122 | 123 | The `done` slot prop is a boolean that indicates that the query has been completed. 124 | 125 | ### errors 126 | 127 | The `errors` slot prop contains all errors encountered when running the query. 128 | 129 | ```vue{1,3} 130 | 131 | 132 | 133 | 134 |
135 |

{{ todo.text }}

136 |
137 | 138 |
139 | ``` 140 | 141 | ### execute 142 | 143 | Sometimes you want to re-fetch the query or run it after some action, the `execute` slot prop is a function that re-runs the query. This example executes the query after the button has been clicked, note that the query is still fetched initially. 144 | 145 | ```vue{1,6} 146 | 147 |
148 |
    149 |
  • {{ post.title }}
  • 150 |
151 | 152 |
153 |
154 | ``` 155 | 156 | ## Caching 157 | 158 | Unique queries are cached in memory, the uniqueness here is an id calculated by the query body, and its variables. Meaning if the same query is run with the same variables it will be fetched from the cache by default and will not hit the network. **Cache is deleted after the user closes/refreshes the page.** 159 | 160 | By default the client uses `cache-first` policy to handle queries, the full list of available policies are: 161 | 162 | - `cache-first`: If found in cache return it, otherwise fetch it from the network. 163 | - `network-only`: Always fetch from the network and do not cache it. 164 | - `cache-and-network`: If found in cache return it, but fetch the fresh value and cache it for next time, if not found in cache it will fetch it from network and cache it. 165 | 166 | You can force the **Query** component to fetch using any of the policies mentioned, you can do this by passing a `cachePolicy` option to the `execute` slot prop: 167 | 168 | ```vue{6} 169 | 170 |
171 |
    172 |
  • {{ post.title }}
  • 173 |
174 | 175 |
176 |
177 | ``` 178 | 179 | :::tip 180 | Calling `execute` with a different cache policy will not change the default policy, the policy you specify will always be used for the next request upon calling `execute`. 181 | ::: 182 | 183 | ### Setting default cache policy 184 | 185 | You can set the default policy when you are [providing the GraphQL client](./client.md) by passing `cachePolicy` option to the `createClient` function. 186 | 187 | ```js{3} 188 | const client = createClient({ 189 | url: '/graphql', // Your endpoint 190 | cachePolicy: 'network-only' 191 | }); 192 | ``` 193 | 194 | This will make all the **Query** components under the **Provider** tree use the `network-only` policy by default, you can still override with the `execute` slot prop. 195 | 196 | ### Cache Prop 197 | 198 | You could also pass the `cachePolicy` prop to the `Query` component to set its default caching policy explicitly. 199 | 200 | ```vue{3} 201 | 206 |
207 |
    208 |
  • {{ post.title }}
  • 209 |
210 |
211 |
212 | ``` 213 | 214 | ## Watching Variables 215 | 216 | Often you want to re-fetch the query when a variable changes, this is done for you by default as long as the query uses `variables` prop. 217 | 218 | ```vue{3} 219 | 224 |
225 |

{{ data.post.title }}

226 |
227 |
228 | ``` 229 | 230 | :::tip 231 | This examples re-runs the query whenever the `id` changes, the results of re-fetched queries follows the configured cache-policy. 232 | ::: 233 | 234 | ### Disabling variable watching 235 | 236 | You can disable the mentioned behavior by setting `pause` prop to `true`. 237 | 238 | ```vue{4} 239 | 245 |
246 |

{{ data.post.title }}

247 |
248 |
249 | ``` 250 | -------------------------------------------------------------------------------- /test/query.ts: -------------------------------------------------------------------------------- 1 | import { mount, createLocalVue } from '@vue/test-utils'; 2 | import flushPromises from 'flush-promises'; 3 | import { withProvider, Query, createClient, Provider } from '../src/index'; 4 | import App from './App.vue'; 5 | 6 | const Vue = createLocalVue(); 7 | Vue.component('Query', Query); 8 | 9 | test('executes queries on mounted', async () => { 10 | const client = createClient({ 11 | url: 'https://test.baianat.com/graphql' 12 | }); 13 | 14 | const AppWithGQL = withProvider(App, client); 15 | 16 | const wrapper = mount(AppWithGQL, { sync: false, localVue: Vue }); 17 | await flushPromises(); 18 | expect(wrapper.findAll('li').length).toBe(5); 19 | }); 20 | 21 | test('caches queries by default', async () => { 22 | const client = createClient({ 23 | url: 'https://test.baianat.com/graphql' 24 | }); 25 | 26 | const wrapper = mount( 27 | { 28 | data: () => ({ 29 | client 30 | }), 31 | components: { 32 | Query, 33 | Provider 34 | }, 35 | template: ` 36 |
37 | 38 |
39 | 40 |
41 |
    42 |
  • {{ post.title }}
  • 43 |
44 | 45 |
46 |
47 |
48 |
49 |
50 | ` 51 | }, 52 | { sync: false } 53 | ); 54 | 55 | await flushPromises(); 56 | expect(fetch).toHaveBeenCalledTimes(1); 57 | 58 | wrapper.find('button').trigger('click'); 59 | await flushPromises(); 60 | // cache was used. 61 | expect(fetch).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | test('cache policy can be overridden with execute function', async () => { 65 | const client = createClient({ 66 | url: 'https://test.baianat.com/graphql' 67 | }); 68 | 69 | const wrapper = mount( 70 | { 71 | data: () => ({ 72 | client 73 | }), 74 | components: { 75 | Query, 76 | Provider 77 | }, 78 | template: ` 79 |
80 | 81 |
82 | 83 |
84 |
    85 |
  • {{ post.title }}
  • 86 |
87 | 88 |
89 |
90 |
91 |
92 |
93 | ` 94 | }, 95 | { sync: false } 96 | ); 97 | 98 | await flushPromises(); 99 | expect(fetch).toHaveBeenCalledTimes(1); 100 | 101 | wrapper.find('button').trigger('click'); 102 | await flushPromises(); 103 | // fetch was triggered a second time. 104 | expect(fetch).toHaveBeenCalledTimes(2); 105 | }); 106 | 107 | test('cache policy can be overridden with cachePolicy prop', async () => { 108 | const client = createClient({ 109 | url: 'https://test.baianat.com/graphql' 110 | }); 111 | 112 | const wrapper = mount( 113 | { 114 | data: () => ({ 115 | client 116 | }), 117 | components: { 118 | Query, 119 | Provider 120 | }, 121 | template: ` 122 |
123 | 124 |
125 | 126 |
127 |
    128 |
  • {{ post.title }}
  • 129 |
130 | 131 |
132 |
133 |
134 |
135 |
136 | ` 137 | }, 138 | { sync: false } 139 | ); 140 | 141 | await flushPromises(); 142 | expect(fetch).toHaveBeenCalledTimes(1); 143 | 144 | wrapper.find('button').trigger('click'); 145 | await flushPromises(); 146 | // fetch was triggered a second time. 147 | expect(fetch).toHaveBeenCalledTimes(2); 148 | }); 149 | 150 | test('variables are watched by default', async () => { 151 | const client = createClient({ 152 | url: 'https://test.baianat.com/graphql' 153 | }); 154 | 155 | const wrapper = mount( 156 | { 157 | data: () => ({ 158 | client, 159 | id: 12 160 | }), 161 | components: { 162 | Query, 163 | Provider 164 | }, 165 | template: ` 166 |
167 | 168 |
169 | 170 |
171 |

{{ data.post.title }}

172 |
173 |
174 |
175 |
176 |
177 | ` 178 | }, 179 | { sync: false } 180 | ); 181 | 182 | await flushPromises(); 183 | expect(fetch).toHaveBeenCalledTimes(1); 184 | expect(wrapper.find('h1').text()).toContain('12'); 185 | wrapper.setData({ 186 | id: 13 187 | }); 188 | await flushPromises(); 189 | // fetch was triggered a second time, due to variable change. 190 | expect(fetch).toHaveBeenCalledTimes(2); 191 | expect(wrapper.find('h1').text()).toContain('13'); 192 | }); 193 | 194 | test('variables watcher can be disabled', async () => { 195 | const client = createClient({ 196 | url: 'https://test.baianat.com/graphql' 197 | }); 198 | 199 | const wrapper = mount( 200 | { 201 | data: () => ({ 202 | client, 203 | id: 12 204 | }), 205 | components: { 206 | Query, 207 | Provider 208 | }, 209 | template: ` 210 |
211 | 212 |
213 | 214 |
215 |

{{ data.post.title }}

216 |
217 |
218 |
219 |
220 |
221 | ` 222 | }, 223 | { sync: false } 224 | ); 225 | 226 | await flushPromises(); 227 | expect(fetch).toHaveBeenCalledTimes(1); 228 | expect(wrapper.find('h1').text()).toContain('12'); 229 | wrapper.setData({ 230 | id: 13 231 | }); 232 | await flushPromises(); 233 | expect(fetch).toHaveBeenCalledTimes(1); 234 | expect(wrapper.find('h1').text()).toContain('12'); 235 | }); 236 | 237 | test('variables prop arrangement does not trigger queries', async () => { 238 | const client = createClient({ 239 | url: 'https://test.baianat.com/graphql' 240 | }); 241 | 242 | const wrapper = mount( 243 | { 244 | data: () => ({ 245 | client, 246 | vars: { 247 | id: 12, 248 | type: 'test' 249 | } 250 | }), 251 | components: { 252 | Query, 253 | Provider 254 | }, 255 | template: ` 256 |
257 | 258 |
259 | 260 |
261 |

{{ data.post.title }}

262 |
263 |
264 |
265 |
266 |
267 | ` 268 | }, 269 | { sync: false } 270 | ); 271 | 272 | await flushPromises(); 273 | expect(fetch).toHaveBeenCalledTimes(1); 274 | expect(wrapper.find('h1').text()).toContain('12'); 275 | (wrapper.vm as any).vars = { 276 | type: 'test', 277 | id: 12 278 | }; 279 | await flushPromises(); 280 | expect(fetch).toHaveBeenCalledTimes(1); 281 | 282 | (wrapper.vm as any).vars.id = 13; 283 | await flushPromises(); 284 | // fetch was triggered a second time, due to variable change. 285 | expect(fetch).toHaveBeenCalledTimes(2); 286 | expect(wrapper.find('h1').text()).toContain('13'); 287 | }); 288 | 289 | test('Handles query errors', async () => { 290 | const client = createClient({ 291 | url: 'https://test.baianat.com/graphql' 292 | }); 293 | 294 | const wrapper = mount( 295 | { 296 | data: () => ({ 297 | client 298 | }), 299 | components: { 300 | Query, 301 | Provider 302 | }, 303 | template: ` 304 | 305 |
306 | 307 |
    308 |
  • {{ post.title }}
  • 309 |
310 |

{{ errors[0].message }}

311 |
312 |
313 |
314 | ` 315 | }, 316 | { sync: false } 317 | ); 318 | 319 | await flushPromises(); 320 | expect(wrapper.find('#error').text()).toMatch(/Cannot query field/); 321 | }); 322 | 323 | test('Handles external errors', async () => { 324 | const client = createClient({ 325 | url: 'https://test.baianat.com/graphql' 326 | }); 327 | 328 | (global as any).fetchController.simulateNetworkError = true; 329 | 330 | const wrapper = mount( 331 | { 332 | data: () => ({ 333 | client 334 | }), 335 | components: { 336 | Query, 337 | Provider 338 | }, 339 | template: ` 340 | 341 |
342 | 343 |
    344 |
  • {{ post.title }}
  • 345 |
346 |

{{ errors[0].message }}

347 |
348 |
349 |
350 | ` 351 | }, 352 | { sync: false } 353 | ); 354 | 355 | await flushPromises(); 356 | expect(wrapper.find('#error').text()).toMatch(/Network Error/); 357 | }); 358 | --------------------------------------------------------------------------------