diff --git a/.travis.yml b/.travis.yml index 3ea5d4f..0f02967 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,7 @@ node_js: -- "4" -- "5" -- "6" -- "7" +- "10" +- "12" +- "14" sudo: false language: node_js script: "npm run test" diff --git a/README.md b/README.md index 3fcdd2b..88a1ffa 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![npm version][2]][3] [![build status][4]][5] [![downloads][8]][9] [![js-standard-style][10]][11] -Tiny graphQL client library. Does everything you need with GraphQL 15 lines of -code. +Tiny GraphQL client library. Compiles queries, fetches them and caches them, all +in one tiny package. ## Usage ```js -var gql = require('nanographql') +const { gql, nanographql } = require('nanographql') -var query = gql` +const Query = gql` query($name: String!) { movie (name: $name) { releaseDate @@ -17,24 +17,143 @@ var query = gql` } ` -try { - var res = await fetch('/query', { - body: query({ name: 'Back to the Future' }), - method: 'POST' - }) - var json = res.json() - console.log(json) -} catch (err) { - console.error(err) -} +const graphql = nanographql('/graphql') +const { errors, data } = graphql(Query({ name: 'Back to the Future' })) + ``` ## API -### `query = gql(string)` +### ``query = gql`[query]` `` Create a new graphql query function. -### `json = query([data])` -Create a new query object that can be sent as `application/json` to a server. +### `operation = query([data])` +Create a new operation object that holds all data necessary to execute the query +against an endpoint. An operation can be stringified to a query (`toString`), +serialized to a plain object (`toJSON`) or iterated over. + +### `cache = nanographql(url[, opts])` +Create a managed cache which fetches data as it is requested. + +#### Options +- **`cache`:** a custom cache store. Should implement `get` and `set` methods. + The default is a [`Map`][15]. +- **`fetch`:** a custom [`fetch`][12] implementation. The `fetch` option should + be a function which takes three arguments, `url`, `opts` and a callback + function. The callback function should be called whenever there is an error or + new data available. The default is an implementation of `window.fetch`. + +### `result = cache(operation[, opts][, cb])` +Query the cache and fetch query if necessary. The options match that of +[`fetch`][13] with a couple extra options. The callback will be called whenever +an error or new data becomes available. + +#### Options +The options are forwarded to the [`fetch`][12] implementation but a few are +also used to determine when to use the cache and how to format the request. + +##### Default options +- **`cache`:** The default behavior of nanographql mimics that of `force-cache` + as it will always try and read from the cache unless specified otherwise. Any + of the values `no-store`, `reload`, `no-cache`, `default` will cause + nanographql to bypass the cache and call the fetch implementation. The value + `no-store` will also prevent the response from being cached locally. See + [cache mode][14] for more details. +- **`body`:** If a body is defined, nanographql will make no changes to headers + or the body itself. You'll have to append the operation to the body yourself. +- **`method`:** If the operation is a `mutation` or if the stringified + operation is too long to be transferred as `GET` parameters, the method will + be set to `POST`, unless specified otherwise. + +##### Extra options +- **`key|key(variables[, cached])`:** A unique identifier for the requested + data. Can be a string or a function. Functions will be called with the + variables and the cached data, if there is any. This can be used to determine + the key of e.g. a mutation where the key is not known untill a response is + retrieved. The default is the variables as a serialized string, or a + stringified representation of the query if no variables are provided. +- **`parse(response[, cached])`:** Parse the incoming data before comitting to + the cache. +- **`mutate(cached)`:** Mutate the cached data prior to reading from cache or + fetching data. This is useful for e.g. immedately updating the UI while + submitting changes to the back end. + +## Expressions and Operations +One of the benefits of GraphQL is the strucuted format of the queries. When +passing a query to the `gql` tag, nanographql will parse the string identifying +individual queries, mutations, subscritions and fragments and expose these as +individual operations. It will also mix in interpolated fragments from other +queries. + +```js +const choo = require('choo') +const html = require('choo/html') +const { gql, nanographql } = require('nanographql') + +const { user } = gql` + fragment user on User { + id + name + } +` + +const { GetUser, SaveUser } = gql` + query GetUser($id: ID!) { + user: getUser(id: $id) { + ...${user} + } + } + mutation SaveUser($id: ID!, $name: String) { + user: saveUser(id: $id, name: $name) { + ...${user} + } + } +` + +const app = choo() + +app.use(store) +app.route('/', main) +app.mount(document.body) + +function store (state, emitter) { + const graphql = nanographql('/graphql') + + state.api = (...args) => graphql(...args, render) + + function render () { + emitter.emit('render') + } +} + +function main (state, emit) { + const { api } = state + const { errors, data } = api(GetUser({ id: 'abc123' }), { key: 'abc123' }) + + if (errors) return html`

User not found

` + if (!data) return html`

Loading

` + + return html` + +
+ Name: + +
+ + ` + + function onsubmit (event) { + api(SaveUser({ id: 'abc123', name: this.username.value }), { + key: 'abc123', + mutate (cached) { + const user = { ...cached.data.user, name } + return { data: { user } } + } + }) + event.preventDefault() + } +} +``` + ## License [MIT](https://tldrlegal.com/license/mit-license) @@ -51,3 +170,7 @@ Create a new query object that can be sent as `application/json` to a server. [9]: https://npmjs.org/package/nanographql [10]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square [11]: https://github.com/feross/standard +[12]: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch +[13]: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters +[14]: https://developer.mozilla.org/en-US/docs/Web/API/Request/cache +[15]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map diff --git a/index.js b/index.js index df2afef..06b0941 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,220 @@ -module.exports = nanographql - -var getOpname = /(query|mutation) ?([\w\d-_]+)? ?\(.*?\)? \{/ - -function nanographql (str) { - str = Array.isArray(str) ? str.join('') : str - var name = getOpname.exec(str) - return function (variables) { - var data = { query: str } - if (variables) data.variables = JSON.stringify(variables) - if (name && name.length) { - var operationName = name[2] - if (operationName) data.operationName = name[2] +const parsed = new WeakMap() +const bypass = ['no-store', 'reload', 'no-cache', 'default'] +const getPlaceholders = /(\.\.\.)?\0(\d+)\0/g +const getOperations = /\s*(query|subscription|mutation|fragment)\s*(\w+)?\s*(?:(?:\(.*?\))|on\s*\w+)?\s*\{/g + +class Operation { + constructor ({ key, type, query, variables, operationName }) { + this.operationName = operationName + this.variables = variables + this.query = query + this.type = type + this.key = key + } + + * [Symbol.iterator] () { + yield ['query', this.query] + yield ['variables', this.variables] + if (this.operationName) yield ['operationName', this.operationName] + } + + toString () { + const { query, variables, operationName } = this + const parts = [ + `query=${encodeURIComponent(query.replace(/\s+/g, ' ').trim())}`, + `variables=${encodeURIComponent(JSON.stringify(variables))}` + ] + if (operationName) parts.push(`operationName=${operationName}`) + return parts.join('&') + } + + toJSON () { + let { query, variables, operationName } = this + query = query.replace(/\s+/g, ' ').trim() + return { query, variables, operationName } + } +} + +exports.gql = gql +exports.Operation = Operation +exports.nanographql = nanographql + +function nanographql (url, opts = {}) { + let { cache, fetch } = opts + + if (!cache) cache = new Map() + if (typeof fetch !== 'function') { + fetch = function (url, opts, cb) { + window.fetch(url, opts).then(function (res) { + return res.json() + }).then(function (res) { + return cb(null, res) + }, cb) + } + } + + return function (operation, opts = {}, cb = Function.prototype) { + if (typeof opts === 'function') { + cb = opts + opts = {} + } + + let { method, body, headers } = opts + const { variables, type } = operation + const querystring = operation.toString() + let href = url.toString() + + let key = opts.key + if (!key) key = variables ? serialize(variables) : querystring + else if (typeof key === 'function') key = opts.key(variables) + + let useCache = !body && type !== 'mutation' && !bypass.includes(opts.cache) + let store = cache.get(operation.key) + if (!store) cache.set(operation.key, store = {}) + let cached = store[key] + + if (opts.mutate || useCache) { + if (opts.mutate) cached = store[key] = opts.mutate(cached) + if (useCache) { + if (cached != null) return cached + if (opts.cache === 'only-if-cached') return {} + } + } + + if (body || type === 'mutation' || (href + querystring).length >= 2000) { + method = method || 'POST' + if (!body) { // Don't bother with custom bodies + body = JSON.stringify(operation) + headers = { ...headers, 'Content-Type': 'application/json' } + } + } else { + let [domainpath, query] = href.split('?') + query = query ? `${query}&${querystring}` : querystring + href = `${domainpath}?${query}` + } + + let errored = false + fetch(href, { ...opts, method, headers, body }, function (err, res) { + useCache = true // it's not really cached when resolved sync + if (err) { + delete store[key] + errored = true + return cb(err) + } + if (typeof opts.key === 'function') key = opts.key(variables, res) + if (typeof opts.parse === 'function') res = opts.parse(res, store[key]) + if (opts.cache !== 'no-store') store[key] = res + cb(null, res) + }) + + cached = store[key] + if (!cached && !errored) store[key] = {} + if (errored || !useCache) return {} + return store[key] || {} + } +} + +function gql (strings, ...values) { + let operation = parsed.get(strings) + if (operation) return operation + operation = parse(strings, values) + parsed.set(strings, operation) + return operation +} + +function parse (strings, values) { + // Compile query with placeholders for partials + const template = strings.reduce(function (query, str, index) { + query += str + if (values[index] != null) query += `\u0000${index}\u0000` + return query + }, '') + + let match + const operations = [] + + // Extract individual operations from template + while ((match = getOperations.exec(template))) { + const index = getOperations.lastIndex + const [query, type, name] = match + const prev = operations[operations.length - 1] + if (prev) { + prev.query += template.substring(prev.index, index - query.length) } - return JSON.stringify(data) + operations.push({ type, name, query, index }) } + + // Add on trailing piece of template + const last = operations[operations.length - 1] + if (last) last.query += template.substring(last.index) + + // Inject fragment into operation query + const fragments = operations.filter(function (operation) { + return operation.type === 'fragment' + }) + if (fragments.length) { + for (const operation of operations) { + if (operation.type === 'fragment') continue + for (const fragment of fragments) { + if (operation.query.includes(`...${fragment.name}`)) { + operation.query += fragment.query + } + } + } + } + + // Decorate base operation + for (const operation of operations) { + const name = operation.name || operation.type + Object.defineProperty(createOperation, name, { + value (variables) { + return new Operation({ + variables, + key: template, + type: operation.type, + operationName: operation.name, + query: compile(operation.query, variables, values) + }) + } + }) + } + + return createOperation + + function createOperation (variables) { + return new Operation({ + variables, + key: template, + query: compile(template, variables, values) + }) + } +} + +function compile (template, variables, values) { + const external = new Set() + let query = template.replace(getPlaceholders, function (_, spread, index) { + let value = values[+index] + if (typeof value === 'function') value = value(variables) + if (value instanceof Operation) { + if (value.type === 'fragment') { + external.add(value.query) + if (spread) return `...${value.operationName}` + } + return '' + } + if (value == null) return '' + return value + }) + query += Array.from(external).join(' ') + return query +} + +// Serialize object into a predictable (sorted by key) string representation +function serialize (obj, prefix = '') { + return Object.keys(obj).sort().map(function (key) { + const value = obj[key] + const name = prefix ? `${prefix}.${key}` : key + if (value && typeof value === 'object') return serialize(obj, key) + return `${name}=${value}` + }).join('&') } diff --git a/package.json b/package.json index bda448d..abcbe03 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,8 @@ "start": "node .", "test": "standard && node test" }, - "dependencies": {}, "devDependencies": { - "spok": "^0.8.1", - "standard": "^10.0.2", + "standard": "^16.0.1", "tape": "^4.7.0" }, "keywords": [ diff --git a/test.js b/test.js index 4117c19..80da9d5 100644 --- a/test.js +++ b/test.js @@ -1,67 +1,651 @@ -var spok = require('spok') -var tape = require('tape') -var gql = require('./') - -tape('should create a query', function (assert) { - var query = gql` - query($number_of_repos:Int!) { - viewer { +const tape = require('tape') +const querystring = require('querystring') +const { gql, nanographql, Operation } = require('./') + +tape('operation interface', function (t) { + t.plan(5) + const expected = { + operationName: 'Greeting', + variables: { hello: 'world' }, + query: 'query Greeting { hello }' + } + const operation = new Operation({ ...expected, key: 'hi' }) + + t.equal(operation.toString(), [ + `query=${encodeURIComponent('query Greeting { hello }')}`, + `variables=${encodeURIComponent('{"hello":"world"}')}`, + 'operationName=Greeting' + ].join('&'), 'string representation as query string') + t.deepEqual(operation.toJSON(), expected, 'json representation match') + + for (const [key, value] of operation) { + t.equal(value, expected[key], `iterator expose ${key}`) + } + + t.end() +}) + +tape('should resolve to operation', function (t) { + const query = gql` + { + hello + } + ` + t.equal(typeof query, 'function', 'is function') + t.doesNotThrow(query, 'does not throw') + + const operation = query({ value: 1 }) + t.ok(operation instanceof Operation, 'resolves to Operation') + t.deepEqual(operation.variables, { value: 1 }, 'operation has variables') + t.equal(operation.query, ` + { + hello + } + `, 'operation has query') + t.end() +}) + +tape('should expose operations by type', function (t) { + const { query, mutation, subscription } = gql` + query { + hello { name - repositories(last: $number_of_repos) { - nodes { - name - } - } + } + } + mutation { + greet(name: $name) { + name + } + } + subscription { + friendship(name: $name) { + relation } } ` - var variables = { number_of_repos: 3 } - var data = query(variables) - spok(assert, JSON.parse(data), { - query: spok.string, - variables: JSON.stringify(variables) - }) - assert.end() + t.equal(typeof query, 'function', 'query is exposed') + t.equal(typeof mutation, 'function', 'mutation is exposed') + t.equal(typeof subscription, 'function', 'subscription is exposed') + + const operation = mutation({ name: 'Jane Doe' }) + t.ok(operation instanceof Operation, 'mutation resolves to Operation') + t.equal(operation.query.trim(), ` + mutation { + greet(name: $name) { + name + } + } + `.trim(), 'mutation has query') + + t.end() }) -tape('should have a name', function (assert) { - var query = gql` - query foo ($number_of_repos:Int!) { - viewer { +tape('should expose named operations', function (t) { + const query = gql` + query Introduction { + hello { name - repositories(last: $number_of_repos) { - nodes { - name - } + } + } + mutation Handshake($name: String!) { + greet(name: $name) { + ...person + } + } + subscription Friendship($name: String!) { + friendship(name: $name) { + relation + person { + ...person } } } + fragment person on Friend { + name + } + ` + + t.equal(typeof query.Introduction, 'function', 'Introduction is exposed') + t.equal(typeof query.Handshake, 'function', 'Handshake is exposed') + t.equal(typeof query.Friendship, 'function', 'Friendship is exposed') + t.equal(typeof query.person, 'function', 'fragment is exposed') + + const introduction = query.Introduction() + t.ok(introduction instanceof Operation, 'introduction resolves to Operation') + t.equal(introduction.query.trim(), ` + query Introduction { + hello { + name + } + } + `.trim(), 'introduction has query') + + const handshake = query.Handshake({ name: 'Jane Doe' }) + t.deepEqual(handshake.variables, { name: 'Jane Doe' }, 'handshake has variables') + t.equal(handshake.query.trim(), ` + mutation Handshake($name: String!) { + greet(name: $name) { + ...person + } + } + fragment person on Friend { + name + } + `.trim(), 'handshake has fragment') + + t.end() +}) + +tape('should support expressions', function (t) { + const { person } = gql` + fragment person on Person { + name + } + ` + const { GetPerson, GetPeople, UpdatePerson } = gql` + query GetPerson { + getPerson(name: "${'Jane Doe'}") { + name + } + } + query GetPeople { + getPeople { + ...${person} + } + } + mutation UpdatePerson { + updatePerson(name: "${(variables) => variables.name}") { + name + } + } ` - var variables = { number_of_repos: 3 } - var data = query(variables) - spok(assert, JSON.parse(data), { - query: spok.string, - operationName: 'foo', - variables: JSON.stringify(variables) + let operation = GetPerson() + t.equal(operation.query.trim(), ` + query GetPerson { + getPerson(name: "Jane Doe") { + name + } + } + `.trim(), 'interpolates string expressions') + + operation = GetPeople() + t.equal(operation.query.trim(), ` + query GetPeople { + getPeople { + ...person + } + } + fragment person on Person { + name + } + `.trim(), 'interpolates fragment operation') + + operation = UpdatePerson({ name: 'Jane Doe' }) + t.equal(operation.query.trim(), ` + mutation UpdatePerson { + updatePerson(name: "Jane Doe") { + name + } + } + `.trim(), 'interpolates function expressions') + + t.end() +}) + +tape('fetch handler', function (t) { + t.test('with query', function (t) { + t.plan(7) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello + } + ` + + let shouldFail = true + graphql(Query({ hello: 'world' }), function (err, res) { + t.ok(err, 'callback received error') + }) + + shouldFail = false + graphql(Query({ hello: 'world' }), function (err, res) { + t.notOk(err) + t.deepEqual(res, { data: { hello: 'hi!' } }, 'callback received data') + }) + + function fetch (url, opts, cb) { + t.equal(url, '/graphql?query=query%20Query%20%7B%20hello%20%7D&variables=%7B%22hello%22%3A%22world%22%7D&operationName=Query', 'payload is encoded as query string') + t.equal(typeof cb, 'function', 'forwards callback') + if (shouldFail) cb(new Error('fail')) + else cb(null, { data: { hello: 'hi!' } }) + } + }) + + t.test('with large query', function (t) { + t.plan(4) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello + } + ` + + const variables = { value: '' } + for (let i = 0; i < 2000; i++) variables.value += 'a' + const operation = Query(variables) + graphql(operation) + + function fetch (url, opts, cb) { + t.ok(opts.body, 'body is set') + const body = JSON.parse(opts.body) + t.deepEqual(body, operation.toJSON(), 'operation is encoded as json body') + t.equal(opts.headers['Content-Type'], 'application/json', 'header is set to json') + t.equal(opts.method, 'POST', 'method is POST') + cb(null) + } + }) + + t.test('with mutation', function (t) { + t.plan(6) + + const graphql = nanographql('/graphql', { fetch }) + const { Mutation } = gql` + mutation Mutation { + hello + } + ` + + const operation = Mutation({ hello: 'world' }) + graphql(operation, function (err, res) { + t.notOk(err) + }) + + function fetch (url, opts, cb) { + t.ok(opts.body, 'body is set') + const body = JSON.parse(opts.body) + t.equal(url, '/graphql', 'url is untouched') + t.deepEqual(body, operation.toJSON(), 'payload is json encoded') + t.equal(opts.headers['Content-Type'], 'application/json', 'header is set to json') + t.equal(opts.method, 'POST', 'method is POST') + cb(null) + } + }) + + t.test('with body', function (t) { + t.plan(4) + + const graphql = nanographql('/graphql', { fetch }) + const { Mutation } = gql` + mutation Mutation { + hello + } + ` + + const method = 'UPDATE' + const body = 'hello=world' + const contentType = 'application/x-www-form-urlencoded' + const operation = Mutation({ hello: 'world' }) + graphql(operation, { + body, + method, + headers: { 'Content-Type': contentType } + }, function (err, res) { + t.notOk(err) + }) + + function fetch (url, opts, cb) { + t.equal(opts.body, body, 'body is preserved') + t.equal(opts.headers['Content-Type'], contentType, 'content type is preserved') + t.equal(opts.method, method, 'method is preserved') + cb(null) + } + }) + + t.test('synchronous resolution', function (t) { + t.plan(4) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello + } + ` + + let shouldFail = true + let res = graphql(Query({ hello: 'world' }), function (err, res) { + t.ok(err, 'callback received error') + }) + t.deepEqual(res, {}, 'resolves to empty object on error') + + shouldFail = false + res = graphql(Query({ hello: 'world' }), function (err, res) { + t.notOk(err) + }) + t.deepEqual(res, { data: { hello: 'hi!' } }, 'synchronously resolved result') + + function fetch (url, opts, cb) { + if (shouldFail) cb(new Error('fail')) + else cb(null, { data: { hello: 'hi!' } }) + } + }) + + t.test('asynchronous resolution', function (t) { + t.plan(3) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello + } + ` + + let shouldFail = true + const sequence = init() + sequence.next() + + function * init () { + let res = graphql(Query({ hello: 'world' })) + t.deepEqual(res, {}, 'resolves to empty object while loading') + yield + shouldFail = false + res = graphql(Query({ hello: 'world' })) + t.deepEqual(res, {}, 'resolves to empty object while loading after error') + yield + res = graphql(Query({ hello: 'world' })) + t.deepEqual(res, { data: { hello: 'hi!' } }, 'resolved result') + } + + function fetch (url, opts, cb) { + setTimeout(function () { + if (shouldFail) cb(new Error('fail')) + else cb(null, { data: { hello: 'hi!' } }) + sequence.next() + }, 100) + } }) - assert.end() }) -tape('should have a name for mutations also', function (assert) { - var query = gql` - mutation CreateSomethingBig($input: Idea!) { - createSomething(input: $input) { - result +tape('cache handler', function (t) { + t.test('cache by query', function (t) { + t.plan(3) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello } + ` + + let shouldFail = true + const sequence = init() + sequence.next() + + function * init () { + let res = graphql(Query()) + t.deepEqual(res, {}, 'resolves to empty result while loading') + yield + shouldFail = false + res = graphql(Query()) + t.deepEqual(res, {}, 'error was not cached') + yield + res = graphql(Query()) + t.deepEqual(res, { data: { hello: 'world' } }, 'result was cached') + } + + function fetch (url, opts, cb) { + setTimeout(function () { + if (shouldFail) cb(new Error('fail')) + else cb(null, { data: { hello: 'world' } }) + sequence.next() + }, 100) + } + }) + + t.test('cache by variables', function (t) { + t.plan(4) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query($value: String!) { + echo(value: $value) + } + ` + + const sequence = init() + sequence.next() + + function * init () { + let foo = graphql(Query({ value: 'foo' })) + t.deepEqual(foo, {}, 'resolves to empty result while loading') + yield + let bar = graphql(Query({ value: 'bar' })) + t.deepEqual(bar, {}, 'resolves to empty result while loading') + yield + foo = graphql(Query({ value: 'foo' })) + t.deepEqual(foo, { data: { echo: { value: 'foo' } } }, 'result was cached by foo value') + bar = graphql(Query({ value: 'bar' })) + t.deepEqual(bar, { data: { echo: { value: 'bar' } } }, 'result was cached by bar value') + } + + function fetch (url, opts, cb) { + setTimeout(function () { + const query = querystring.parse(url.split('?')[1]) + cb(null, { data: { echo: JSON.parse(query.variables) } }) + sequence.next() + }, 100) + } + }) + + t.test('cache by key option', function (t) { + t.plan(8) + + const graphql = nanographql('/graphql', { fetch }) + const { Query, Mutation } = gql` + query Query($value: String!) { + key(value: $value) + } + mutation Mutation($value: String!) { + key(value: $value) + } + ` + + const sequence = init() + sequence.next() + + function * init () { + let foo = graphql(Query({ value: 'foo' }), { key: keyFn }) + t.deepEqual(foo, {}, 'resolves to empty result while loading') + yield + graphql(Mutation({ value: 'bin' }, { + key (res) { + return res && res.data.key + } + })) + let bar = graphql(Query({ value: 'bar' }), { key: 'baz' }) + t.deepEqual(bar, { data: { key: 'baz' } }, 'mutation resolved to same key') + yield + foo = graphql(Query({ value: 'foo' }), { key: keyFn }) + bar = graphql(Query({ value: 'bar' }), { key: 'baz' }) + t.deepEqual(foo, { data: { key: 'baz' } }, 'result match') + t.deepEqual(bar, { data: { key: 'baz' } }, 'result match') + } + + function fetch (url, opts, cb) { + setTimeout(function () { + cb(null, { data: { key: 'baz' } }) + sequence.next() + }, 100) + } + + function keyFn (variables, cached) { + t.deepEqual(variables, { value: 'foo' }, 'key function called w/ variables') + if (cached) { + t.deepEqual(cached, { data: { key: 'baz' } }, 'key function called w/ cached value') + } + return 'baz' + } + }) + + t.test('respect only-if-cached option', function (t) { + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello + } + ` + + const res = graphql(Query(), { cache: 'only-if-cached' }) + t.deepEqual(res, {}, 'empty result when not cached') + t.end() + + function fetch (url, opts, cb) { + t.fail('should not fetch') + } + }) + + t.test('respect cache bypass option', function (t) { + t.plan(12) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello + } + ` + + const bypass = ['no-store', 'reload', 'no-cache', 'default'] + let sequence = init(0) + sequence.next() + + function * init (index) { + const cache = bypass[index] + let res = graphql(Query(), { cache, key: cache }) + t.deepEqual(res, {}, `empty result while loading using ${cache}`) + yield + res = graphql(Query(), { cache, key: cache }) + t.deepEqual(res, {}, `was not retrieved from cache using ${cache}`) + yield + res = graphql(Query(), { key: cache }) + if (cache === 'no-store') { + t.deepEqual(res, {}, `was not stored in cache using ${cache}`) + } else { + t.deepEqual(res, { data: { hello: 'hi' } }, `was stored in cache using ${cache}`) + } + + if (index < bypass.length - 1) { + sequence = init(index + 1) + sequence.next() + } + } + + function fetch (url, opts, cb) { + setTimeout(function () { + cb(null, { data: { hello: 'hi' } }) + sequence.next() + }, 100) + } + }) + + t.test('custom cache', function (t) { + const cache = new Map() + const graphql = nanographql('/graphql', { fetch, cache }) + const { Query } = gql` + query Query { + hello + } + ` + + const operation = Query() + graphql(operation, { key: 'key' }) + t.ok(cache.has(operation.key)) + t.deepEqual(cache.get(operation.key).key, { data: { hello: 'hi' } }) + t.end() + + function fetch (url, opts, cb) { + cb(null, { data: { hello: 'hi' } }) + } + }) +}) + +tape('parse', function (t) { + t.plan(5) + + const graphql = nanographql('/graphql', { fetch }) + const { Query } = gql` + query Query { + hello } ` - var data = query() - spok(assert, JSON.parse(data), { - query: spok.string, - operationName: 'CreateSomethingBig' + let res = graphql(Query(), { + parse (res, cached) { + t.deepEqual(res, { data: { hello: 'hi' } }, 'parse got original response') + t.notOk(cached, 'nothing cached on first run') + return { data: { hello: 'hey' } } + } + }) + t.deepEqual(res, { data: { hello: 'hey' } }, 'response was parsed') + + res = graphql(Query(), { + cache: 'no-cache', + parse (res, cached) { + t.deepEqual(cached, { data: { hello: 'hey' } }, 'parse got cached response') + return { data: { hello: 'greetings' } } + } }) - assert.end() + t.deepEqual(res, { data: { hello: 'greetings' } }, 'response was parsed') + + function fetch (url, opts, cb) { + cb(null, { data: { hello: 'hi' } }) + } +}) + +tape('mutate', function (t) { + t.plan(3) + + const graphql = nanographql('/graphql', { fetch }) + const { Query, Mutation } = gql` + query Query { + hello + } + mutation Mutation { + hello + } + ` + + const sequence = init() + sequence.next() + + function * init () { + graphql(Query(), { key: 'foo' }) // Populate cache + + yield + + let res = graphql(Mutation(), { + key: 'foo', + mutate (cached) { + t.deepEqual(cached, { data: { hello: 'hi' } }, 'mutate got cached value') + return { data: { hello: 'hey' } } + } + }) + + res = graphql(Query(), { key: 'foo' }) + t.deepEqual(res, { data: { hello: 'hey' } }, 'got mutated value') + + yield + + res = graphql(Query(), { key: 'foo' }) + t.deepEqual(res, { data: { hello: 'hi' } }, 'mutation was overwritten') + } + + function fetch (url, opts, cb) { + setTimeout(function () { + cb(null, { data: { hello: 'hi' } }) + sequence.next() + }, 100) + } })