├── .gitattributes ├── src ├── vendor.js ├── components │ ├── Out.svelte │ └── Status.svelte ├── main.js └── client.js ├── e2e ├── index.pug ├── components │ ├── helpers.js │ ├── pages │ │ └── pokemon │ │ │ ├── index.js │ │ │ ├── Pokemon.svelte │ │ │ └── PokemonInfo.svelte │ └── App.svelte ├── test.js └── cases │ └── sandbox.test.js ├── .gitignore ├── .github └── workflows │ └── testing.yml ├── Makefile ├── .eslintrc ├── package.json └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json binary 2 | -------------------------------------------------------------------------------- /src/vendor.js: -------------------------------------------------------------------------------- 1 | export { default as FetchQL } from 'fetchql'; 2 | -------------------------------------------------------------------------------- /e2e/index.pug: -------------------------------------------------------------------------------- 1 | meta(charset='utf-8') 2 | #app 3 | script(src='test.js') 4 | -------------------------------------------------------------------------------- /e2e/components/helpers.js: -------------------------------------------------------------------------------- 1 | export function delay(p, ms) { 2 | return new Promise(ok => setTimeout(ok, ms)).then(() => p); 3 | } 4 | -------------------------------------------------------------------------------- /e2e/test.js: -------------------------------------------------------------------------------- 1 | import App from './components/App.svelte'; 2 | 3 | new App({ // eslint-disable-line 4 | target: document.getElementById('app'), 5 | }); 6 | -------------------------------------------------------------------------------- /e2e/components/pages/pokemon/index.js: -------------------------------------------------------------------------------- 1 | export { default as Pokemon } from './Pokemon.svelte'; 2 | export { default as PokemonInfo } from './PokemonInfo.svelte'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.todo 3 | .DS_Store 4 | .nyc_output 5 | docs/*.map 6 | docs/*.js 7 | cache.json 8 | build 9 | chrome 10 | coverage 11 | node_modules 12 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build-test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: tacoss/nodejs@master 17 | with: 18 | args: make ci 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | browser ?= chrome 2 | 3 | help: Makefile 4 | @awk -F':.*?##' '/^[a-z0-9\\%!:-]+:.*##/{gsub("%","*",$$1);gsub("\\\\",":*",$$1);printf "\033[36m%8s\033[0m %s\n",$$1,$$2}' $< 5 | 6 | ci: src deps clean ## Run CI scripts 7 | @npm test -- --color 8 | 9 | dev: src deps ## Start dev tasks 10 | @npm run dev 11 | 12 | e2e: src deps ## Run E2E tests locally 13 | @BROWSER=$(browser) npm test 14 | 15 | deps: package*.json 16 | @(((ls node_modules | grep .) > /dev/null 2>&1) || npm i) || true 17 | 18 | clean: 19 | @rm -f cache.json 20 | @rm -rf build/* 21 | -------------------------------------------------------------------------------- /e2e/components/pages/pokemon/Pokemon.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 |

Pokémon not found!

14 |

{data.pokemon.number}. {data.pokemon.name}

15 | {data.pokemon.name} 16 |
17 |
18 | -------------------------------------------------------------------------------- /e2e/components/pages/pokemon/PokemonInfo.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /e2e/cases/sandbox.test.js: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | /* global fixture, test */ 4 | 5 | function url(x = '') { 6 | return process.env.BASE_URL + x; 7 | } 8 | 9 | fixture('svql') 10 | .page(url()); 11 | 12 | test('it loads...', async t => { 13 | const h1 = Selector('h1'); 14 | 15 | await t 16 | .expect(h1.exists).ok() 17 | .expect(h1.count).eql(1); 18 | 19 | await t.expect(h1.textContent).contains('It works!'); 20 | }); 21 | 22 | test.page(url('/Pikachu'))('it can display pokemon info', async t => { 23 | await t.expect(Selector('h3').textContent).contains('Loading...'); 24 | await t.wait(400).expect(Selector('h3').textContent).contains('025. Pikachu'); 25 | }); 26 | 27 | test.page(url('/ImNotExists'))('it can display failures', async t => { 28 | await t.expect(Selector('h3').textContent).contains('Loading...'); 29 | await t.wait(400).expect(Selector('h3').textContent).contains('Pokémon not found!'); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/Out.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | {#if !nostatus} 18 | 19 | 20 | 21 | {/if} 22 | 23 | {#if promise} 24 | {#await promise} 25 | 26 |

{loading}

27 |
28 | {:then data} 29 | {#if isFailure(data)} 30 | 31 | 32 | 33 | {:else} 34 | 35 | {/if} 36 | {:catch error} 37 | 38 | 39 | 40 | {/await} 41 | {/if} 42 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "browser": true 5 | }, 6 | "extends": "airbnb-base", 7 | "plugins": [ 8 | "svelte3" 9 | ], 10 | "overrides": [ 11 | { 12 | "files": ["*.svelte"], 13 | "processor": "svelte3/svelte3" 14 | } 15 | ], 16 | "parserOptions": { 17 | "ecmaVersion": 2019, 18 | "sourceType": "module" 19 | }, 20 | "rules" : { 21 | "max-len": ["error", { 22 | "code": 150 23 | }], 24 | "arrow-parens": ["error", "as-needed"], 25 | "indent": 0, 26 | "strict": 0, 27 | "prefer-const": 0, 28 | "no-console": 0, 29 | "no-labels": 0, 30 | "no-unused-labels": 0, 31 | "no-restricted-syntax": 0, 32 | "no-multi-assign": 0, 33 | "prefer-destructuring": 0, 34 | "function-paren-newline": 0, 35 | "global-require": 0, 36 | "prefer-spread": 0, 37 | "prefer-rest-params": 0, 38 | "prefer-arrow-callback": 0, 39 | "arrow-body-style": 0, 40 | "no-restricted-globals": 0, 41 | "consistent-return": 0, 42 | "no-param-reassign": 0, 43 | "no-underscore-dangle": 0, 44 | "no-multiple-empty-lines": 0, 45 | "import/first": 0, 46 | "import/extensions": 0, 47 | "import/no-unresolved": 0, 48 | "import/no-dynamic-require": 0, 49 | "import/no-mutable-exports": 0, 50 | "import/no-extraneous-dependencies": 0, 51 | "import/prefer-default-export": 0 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLClient, state$, conn$, query$, mutation$, key$, read$, 3 | } from './client'; 4 | 5 | export { default as Status } from './components/Status.svelte'; 6 | export { default as Out } from './components/Out.svelte'; 7 | 8 | // shared stores 9 | export const state = state$; 10 | export const conn = conn$; 11 | 12 | // accessors/methods 13 | export const key = key$; 14 | export const read = read$; 15 | export const query = query$; 16 | export const mutation = mutation$; 17 | 18 | function _getJSON(sessionKey) { 19 | let _session; 20 | 21 | try { 22 | _session = JSON.parse(localStorage.getItem(sessionKey || 'session')) || {}; 23 | } catch (e) { 24 | _session = {}; 25 | } 26 | 27 | return _session; 28 | } 29 | 30 | export function saveSession(values, sessionKey) { 31 | localStorage.setItem(sessionKey || 'session', JSON.stringify(values || {})); 32 | 33 | if (values && values.token) { 34 | GraphQLClient.setToken(values.token); 35 | } 36 | } 37 | 38 | export function useToken(value, sessionKey) { 39 | saveSession({ ..._getJSON(sessionKey), token: value }, sessionKey); 40 | } 41 | 42 | export function useClient(options, sessionKey) { 43 | const _session = _getJSON(sessionKey); 44 | const _options = options || {}; 45 | const _headers = { ..._options.headers }; 46 | 47 | if (_session.token) { 48 | _headers.Authorization = `Bearer ${_session.token}`; 49 | } 50 | 51 | return new GraphQLClient({ 52 | ..._options, 53 | headers: _headers, 54 | }); 55 | } 56 | 57 | export function setupClient(options, sessionKey) { 58 | GraphQLClient.setClient(useClient(options, sessionKey)); 59 | } 60 | -------------------------------------------------------------------------------- /e2e/components/App.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 51 | 52 | 53 | 54 |

It works!

55 | Go to test-page or 56 | 57 | 63 | {#if name} 64 | 65 | {/if} 66 | 67 | 68 |
69 | 70 | 71 | 72 | 76 |
77 | 81 | 82 |
83 |
84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svql", 3 | "version": "0.0.35", 4 | "description": "FetchQL wrapper for Svelte 3", 5 | "main": "build/dist/main.js", 6 | "module": "build/dist/main.js", 7 | "svelte": "build/main.js", 8 | "files": [ 9 | "build/components/*.*", 10 | "build/dist/main.js", 11 | "build/*.js" 12 | ], 13 | "scripts": { 14 | "lint": "eslint --ext js,svelte src e2e", 15 | "dev": "npm run build:test -- -w src", 16 | "mortero": "mortero -asvql:./src/main.js -r'**:{filepath/1}' -X{cases,components}", 17 | "build:module": "npm run mortero -- src -Dbuild/dist -Nsvelte -fymain.js -B '**/main.js' --format esm", 18 | "build:test": "npm run mortero -- e2e -B'**/test.js'", 19 | "build:all": "mortero src -fK -r'**:{filepath/1}' --format esm", 20 | "build": "npm run build:all && npm run build:module", 21 | "postbuild": "cp build/dist/vendor.js build/", 22 | "prebuild": "rm -rf build", 23 | "prepare": "npm run build", 24 | "test": "npm run build:test && npm run test:e2e", 25 | "test:e2e": "BASE_URL=http://localhost:8080 testcafe ${BROWSER:-chrome:headless} -q -a 'npm run dev' e2e/cases" 26 | }, 27 | "mortero": { 28 | "bundle": [ 29 | "**/vendor.js" 30 | ] 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/pateketrueke/svql.git" 35 | }, 36 | "author": "Alvaro Cabrera ", 37 | "license": "MIT", 38 | "keywords": [ 39 | "svelte", 40 | "fetchql", 41 | "graphql", 42 | "svelte-graphql", 43 | "svelte3-graphql" 44 | ], 45 | "peerDependencies": { 46 | "svelte": "3.x" 47 | }, 48 | "devDependencies": { 49 | "eslint": "^7.27.0", 50 | "eslint-config-airbnb-base": "^14.0.0", 51 | "eslint-plugin-import": "^2.18.2", 52 | "eslint-plugin-svelte3": "^3.2.0", 53 | "eslint-utils": ">=1.4.1", 54 | "fetchql": "^3.0.0", 55 | "mortero": "^0.0.42", 56 | "pug": "^3.0.2", 57 | "smoo": "^0.0.16", 58 | "svelte": "^3.38.2", 59 | "testcafe": "^1.9.4", 60 | "ws": ">=5.2.3", 61 | "xmldom": "^0.6.0", 62 | "yrv": "^0.0.46" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/Status.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | 67 | 68 | {#if from} 69 |
78 | {#await wrap(from)} 79 | 80 |

{pending}

81 |
82 | {:then result} 83 | 84 |

{otherwise}

85 |
86 | {:catch error} 87 | 88 | 89 | 90 | {/await} 91 |
92 | {/if} 93 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import { writable, get } from 'svelte/store'; 2 | import { FetchQL } from './vendor'; 3 | 4 | export const conn$ = writable({}); 5 | export const state$ = writable({}); 6 | 7 | const RE_QUERY_NAME = /(^|\b)(query|mutation)\s*([^{( )}]+)?/i; 8 | const IS_FAILURE = Symbol('@@FAILURE'); 9 | 10 | const seen = []; 11 | const keys = []; 12 | 13 | export function isFailure(value) { 14 | return value === IS_FAILURE || (value instanceof Error && value[IS_FAILURE]); 15 | } 16 | 17 | // https://stackoverflow.com/a/7616484 18 | export function hashCode(value) { 19 | let hash = 0; 20 | let chr; 21 | 22 | if (value.length === 0) return hash; 23 | 24 | for (let i = 0; i < value.length;) { 25 | chr = value.charCodeAt(i); 26 | hash = ((hash << 5) - hash) + chr; // eslint-disable-line 27 | hash |= 0; // eslint-disable-line 28 | i += 1; 29 | } 30 | 31 | return hash; 32 | } 33 | 34 | export function key(c, gql) { 35 | // group-by generic identifier per-client connection! 36 | c._id = c._id || Math.random().toString(36).substr(2); 37 | 38 | const matches = gql.match(RE_QUERY_NAME); 39 | 40 | if (matches) { 41 | if (!seen.includes(gql)) { 42 | seen.push(gql); 43 | } 44 | 45 | const offset = seen.indexOf(gql); 46 | 47 | if (!keys[offset]) { 48 | keys[offset] = matches[3] || hashCode(gql.replace(/\W/g, '')); 49 | } 50 | 51 | return `${c._id}.${keys[offset]}`; 52 | } 53 | 54 | return `${c._id}.${gql}`; 55 | } 56 | 57 | export function read(c, gql) { 58 | return get(state$)[key(c, gql)]; 59 | } 60 | 61 | export function resp(c, gql, result, callback) { 62 | return Promise.resolve() 63 | .then(() => typeof callback === 'function' && callback(result.data)) 64 | .then(retval => { 65 | if (!retval && result.data) { 66 | state$.update(old => Object.assign(old, { [key(c, gql)]: result.data })); 67 | } 68 | 69 | conn$.set({ loading: false }); 70 | 71 | return retval || result.data; 72 | }); 73 | } 74 | 75 | export function query(c, gql, data, callback, onFailure) { 76 | if (typeof data === 'function') { 77 | onFailure = callback; 78 | callback = data; 79 | data = undefined; 80 | } 81 | 82 | conn$.set({ loading: true, failure: null }); 83 | 84 | return Promise.resolve() 85 | .then(() => { 86 | const promise = c 87 | .query({ query: gql, variables: data }) 88 | .then(result => resp(c, gql, result, callback)); 89 | 90 | state$.update(old => Object.assign(old, { [key(c, gql)]: promise })); 91 | 92 | // ensure this value passes isFailure() tests! 93 | return promise.catch(e => { 94 | conn$.set({ loading: null, failure: e }); 95 | 96 | // make sure we can rollback... 97 | if (typeof onFailure === 'function') onFailure(e); 98 | 99 | // flag and rethrow error for later 100 | if (e instanceof Error || Array.isArray(e)) { 101 | e[IS_FAILURE] = true; 102 | 103 | throw e; 104 | } 105 | 106 | return IS_FAILURE; 107 | }); 108 | }); 109 | } 110 | 111 | export function mutation(c, gql, cb = done => done()) { 112 | return function call$(...args) { 113 | cb((data, callback, onFailure) => query(c, gql, data, callback, onFailure)).apply(this, args); 114 | }; 115 | } 116 | 117 | let _client; 118 | 119 | export class GraphQLClient { 120 | constructor(url, options) { 121 | if (typeof url === 'object') { 122 | options = url; 123 | url = options.url; 124 | } 125 | 126 | if (!(url && typeof url === 'string')) { 127 | throw new Error(`Invalid url, given '${options.url}'`); 128 | } 129 | 130 | this.client = new FetchQL({ 131 | url, 132 | onStart(x) { conn$.set({ loading: x > 0 }); }, 133 | onEnd(x) { conn$.set({ loading: x > 0 }); }, 134 | interceptors: [{ 135 | response(_resp) { 136 | if (_resp.status !== 200) { 137 | return _resp.json().then(result => { 138 | if (!result.errors) { 139 | throw new Error(`Unexpected response, given '${_resp.status}'`); 140 | } 141 | 142 | throw result.errors; 143 | }); 144 | } 145 | 146 | return _resp; 147 | }, 148 | }], 149 | ...options, 150 | }); 151 | 152 | this.setToken = token => { 153 | this.client.requestObject.headers.Authorization = `Bearer ${token}`; 154 | }; 155 | 156 | // overload methods/accesors 157 | this.key = (...args) => key(this.client, ...args); 158 | this.read = (...args) => read(this.client, ...args); 159 | this.query = (...args) => query(this.client, ...args); 160 | this.mutation = (...args) => mutation(this.client, ...args); 161 | } 162 | 163 | static setToken(token) { 164 | if (!(_client && _client.client)) { 165 | throw new Error('setupClient() must be called before setToken()!'); 166 | } 167 | 168 | _client.setToken(token); 169 | } 170 | 171 | static setClient(client) { 172 | _client = client; 173 | } 174 | } 175 | 176 | // singleton methods/accessors 177 | function call(fn, name) { 178 | return (...args) => { 179 | if (!(_client && _client.client)) { 180 | throw new Error(`setupClient() must be called before ${name}()!`); 181 | } 182 | 183 | return fn(_client.client, ...args); 184 | }; 185 | } 186 | 187 | export const key$ = call(key, 'key'); 188 | export const read$ = call(read, 'read'); 189 | export const query$ = call(query, 'query'); 190 | export const mutation$ = call(mutation, 'mutation'); 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > The _easiest_ way to consume GraphQL APIs in Svelte3 2 | > 3 | > ![Build status](https://github.com/pateketrueke/svql/workflows/build/badge.svg) 4 | > [![NPM version](https://badge.fury.io/js/svql.svg)](http://badge.fury.io/js/svql) 5 | > [![Known Vulnerabilities](https://snyk.io/test/npm/svql/badge.svg)](https://snyk.io/test/npm/svql) 6 | 7 | ```html 8 | 25 | 26 | 27 |

{data.pokemon.number}. {data.pokemon.name}

28 | {data.pokemon.name} 29 |
30 | ``` 31 | 32 | ## How it works. 33 | 34 | `svql` uses a [fetchql]() singleton to talk to GraphQL. You can configure it through the `setupClient()` method. 35 | 36 | Both `query` and `mutation` helpers will take the GQL and return a promise (or function that returns a promise, respectively). 37 | 38 | ### `query(gql[, data[, callback]]): Promise` 39 | 40 | > Queries are indexed so you can refer to them as `from={MY_GQL_QUERY}`. `data` is optional, as is the `callback` function. Any truthy value returned by this callback will be used in-place of the regular response. 41 | 42 | Accessing those values can be done through `` components as shown above, or by watching the returned promises: 43 | 44 | ```html 45 | 49 | 50 | ``` 51 | 52 | Refetching of queries can be done through reactive statements: 53 | 54 | ```html 55 | 60 | ``` 61 | 62 | Each time `name` changes, the query re-executes. 63 | 64 | ### `mutation(gql[, callback]): Function` 65 | 66 | > The callback will receive a `commit` function that accepts variables-input as first argument, and optionally a second function to handle the response. Values returned by this function are also promises. 67 | 68 | Mutations are functions that could result in more work, so you need to be sure and `commit` once you're ready for the actual request: 69 | 70 | ```html 71 | 83 |

Email:

84 |

Password:

85 | 86 | ``` 87 | 88 | Since `mutation()` returns a function, there's no need to setup reactive statements to _refetch_ it. Just calling the generated function is enough. 89 | 90 | ## Components 91 | 92 | You can access `svql` stores as `conn` and `state` respectively. However, it is better to use the following components to handle state. :sunglasses: 93 | 94 | ### `` 95 | 96 | No longer shipped, use a separate `Failure` component from [smoo](https://github.com/pateketrueke/smoo). 97 | 98 | ### `` 99 | 100 | This takes a `from={promise}` value, then renders its progress, catches the failure, etc. 101 | 102 | Available props: 103 | 104 | - `{from}` — Promise-like value to handle status changes 105 | - `{label}` — Label used for `{:catch error}` handling with `` 106 | - `{fixed}` — Setup `` container as fixed, positioned at `left:0;bottom:0` by default 107 | - `{pending}` — Message while the promise is being resolved... 108 | - `{otherwise}` — Message while once promise has resolved successfully 109 | 110 | > With `fixed` you can provide offsets, e.g. `` 111 | 112 | Available slots: 113 | 114 | - `pending` — Replace the `{:await}` block, default is an `

` 115 | - `otherwise` — Replace the `{:then}` block, default is an `

`; it receives `let:result` 116 | - `exception` — Replace the `{:catch}` block, default is ``; it receives `let:error` 117 | 118 | ### `` 119 | 120 | Use this component to access data `from={promise}` inside, or `from={GQL}` to extract it from resolved state. 121 | 122 | Available props: 123 | 124 | - `{nostatus}` — Boolean; its presence disables the `` render 125 | - `{loading}` — Message while the promise is being resolved... 126 | - `{...}` — Same props from `` 127 | - `let:data` — Unbound `data` inside 128 | 129 | Available slots: 130 | 131 | - `status` — Replaces the `` render with custom markup; it receives the same props as `` 132 | - `loading` — Replace the `{:then}` block, default is an `

`; it receives `let:result` 133 | - `failure` — Replace the `{:catch}` block, default is ``; it receives `let:error` 134 | 135 | ### `` 136 | 137 | No longer shipped, use a separate `Fence` component from [smoo](https://github.com/pateketrueke/smoo). 138 | 139 | > Loading states should be bound as `...` to properly block the UI. 140 | 141 | ## Public API 142 | 143 | - `setupClient(options[, key])` — Configure a `FetchQL` singleton with the given `options`, `key` is used for session loading 144 | - `useClient(options[, key])` — Returns a `FetchQL` instance with the given `options`, `key` is used for session loading 145 | - `useToken(value[, key])` — Update the session-token used for Bearer authentication, `key` is used for session loading 146 | - `saveSession(data[, key])` — Serializes any given value as the current session, it MUST be a plain object or null 147 | - `read(gql|key)` — Retrieve current value from `state` by key, a shorthand for `$state[key]` values 148 | - `key(gql)` — Returns a valid `key` from GQL-strings, otherwise the same value is returned 149 | - `$state` — Store with all resolved state by the `fetchql` singleton 150 | - `$conn` — Store with connection details during `fetchql` requests 151 | 152 | > `sqvl` use **Bearer authentication** by default, so any token found in the session will be sent forth-and-back. 153 | 154 | If you want to change your client's authorization token, you may call `client.setToken()` — or `useToken()` globally. 155 | --------------------------------------------------------------------------------