From 0010b2a69d5553496996f3a14c54d04595fdc4c6 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 3 Aug 2020 10:11:51 -0400 Subject: [PATCH 1/5] feat: accept DocumentNode input closes #176 Example: ```ts import gql from 'graphql-tag' await request('https://foo.bar/graphql', gql`...`) ``` If you don't actually care about using DocumentNode but just want the tooling support for gql template tag like IDE syntax coloring and prettier autoformat then note you can use the passthrough gql tag shipped with graphql-request to save a bit of performance and not have to install another dep into your project. ```ts import { gql } from 'graphql-request' await request('https://foo.bar/graphql', gql`...`) ``` --- package.json | 2 + src/index.ts | 98 ++++++++++++++++++++++++++++++++++++++++----- src/types.ts | 4 ++ tests/__helpers.ts | 24 +++++++++-- tests/index.test.ts | 80 ++++++++++++++++++++++++++++++------ yarn.lock | 10 +++++ 6 files changed, 193 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 5590f05e1..dd625d623 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "dripip": "^0.9.0", "express": "^4.17.1", "fetch-cookie": "0.7.2", + "graphql": "^15.3.0", + "graphql-tag": "^2.11.0", "jest": "^26.0.1", "prettier": "^2.0.5", "ts-jest": "^26.0.0", diff --git a/src/index.ts b/src/index.ts index 6318e0c27..d85fbca5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,13 @@ import fetch from 'cross-fetch' -import { ClientError, GraphQLError, Variables } from './types' +import { print } from 'graphql/language/printer' +import { ClientError, GraphQLError, RequestDocument, Variables } from './types' import { RequestInit, Response } from './types.dom' export { ClientError } from './types' +/** + * todo + */ export class GraphQLClient { private url: string private options: RequestInit @@ -45,11 +49,15 @@ export class GraphQLClient { } } - async request(query: string, variables?: V): Promise { + /** + * todo + */ + async request(document: RequestDocument, variables?: V): Promise { const { headers, ...others } = this.options + const resolvedDoc = resolveRequestDocument(document) const body = JSON.stringify({ - query, + query: resolvedDoc, variables: variables ? variables : undefined, }) @@ -66,13 +74,12 @@ export class GraphQLClient { return result.data } else { const errorResult = typeof result === 'string' ? { error: result } : result - throw new ClientError({ ...errorResult, status: response.status }, { query, variables }) + throw new ClientError({ ...errorResult, status: response.status }, { query: resolvedDoc, variables }) } } setHeaders(headers: Response['headers']): GraphQLClient { this.options.headers = headers - return this } @@ -86,28 +93,71 @@ export class GraphQLClient { } else { this.options.headers = { [key]: value } } + return this } } +/** + * todo + */ export async function rawRequest( url: string, query: string, variables?: V ): Promise<{ data?: T; extensions?: any; headers: Headers; status: number; errors?: GraphQLError[] }> { const client = new GraphQLClient(url) - return client.rawRequest(query, variables) } -export async function request(url: string, query: string, variables?: V): Promise { +/** + * Send a GraphQL Document to the GraphQL server for exectuion. + * + * @example + * + * ```ts + * // You can pass a raw string + * + * await request('https://foo.bar/graphql', ` + * { + * query { + * users + * } + * } + * `) + * + * // You can also pass a GraphQL DocumentNode. Convenient if you + * // are using graphql-tag package. + * + * import gql from 'graphql-tag' + * + * await request('https://foo.bar/graphql', gql`...`) + * + * // If you don't actually care about using DocumentNode but just + * // want the tooling support for gql template tag like IDE syntax + * // coloring and prettier autoformat then note you can use the + * // passthrough gql tag shipped with graphql-request to save a bit + * // of performance and not have to install another dep into your project. + * + * import { gql } from 'graphql-request' + * + * await request('https://foo.bar/graphql', gql`...`) + * ``` + */ +export async function request( + url: string, + document: RequestDocument, + variables?: V +): Promise { const client = new GraphQLClient(url) - - return client.request(query, variables) + return client.request(document, variables) } export default request +/** + * todo + */ function getResult(response: Response): Promise { const contentType = response.headers.get('Content-Type') if (contentType && contentType.startsWith('application/json')) { @@ -116,3 +166,33 @@ function getResult(response: Response): Promise { return response.text() } } + +/** + * helpers + */ + +function resolveRequestDocument(document: RequestDocument): string { + if (typeof document === 'string') return document + + return print(document) +} + +/** + * Convenience passthrough template tag to get the benefits of tooling for the gql template tag. This does not actually parse the input into a GraphQL DocumentNode like graphql-tag package does. It just returns the string with any variables given interpolated. Can save you a bit of performance and having to install another package. + * + * @example + * + * import { gql } from 'graphql-request' + * + * await request('https://foo.bar/graphql', gql`...`) + * + * @remarks + * + * Several tools in the Node GraphQL ecosystem are hardcoded to specially treat any template tag named "gql". For example see this prettier issue: https://github.com/prettier/prettier/issues/4360. Using this template tag has no runtime effect beyond variable interpolation. + */ +export function gql(chunks: TemplateStringsArray, ...variables: any[]): string { + return chunks.reduce( + (accumulator, chunk, index) => `${accumulator}${chunk}${index in variables ? variables[index] : ''}`, + '' + ) +} diff --git a/src/types.ts b/src/types.ts index d152f6dd8..415368654 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { DocumentNode } from 'graphql/language/ast' + export type Variables = { [key: string]: any } export interface GraphQLError { @@ -50,3 +52,5 @@ export class ClientError extends Error { } } } + +export type RequestDocument = string | DocumentNode diff --git a/tests/__helpers.ts b/tests/__helpers.ts index 5db399fd7..bafed427d 100644 --- a/tests/__helpers.ts +++ b/tests/__helpers.ts @@ -9,7 +9,25 @@ type Context = { server: Application nodeServer: Server url: string - mock: (data: D) => D & { requests: CapturedRequest[] } + res: (spec: S) => MockResult +} + +type MockSpec = { + headers?: Record + body?: { + data?: JsonObject + extensions?: JsonObject + errors?: JsonObject + } +} + +type MockResult = { + spec: Spec + requests: { + method: string + headers: Record + body: JsonObject + }[] } export function setupTestServer() { @@ -22,7 +40,7 @@ export function setupTestServer() { ctx.url = 'http://localhost:3210' ctx.nodeServer.on('request', ctx.server) ctx.nodeServer.once('listening', done) - ctx.mock = (spec) => { + ctx.res = (spec) => { const requests: CapturedRequest[] = [] ctx.server.use('*', function mock(req, res) { requests.push({ @@ -37,7 +55,7 @@ export function setupTestServer() { } res.send(spec.body ?? {}) }) - return { ...spec, requests } + return { spec, requests: requests as any } as any } }) diff --git a/tests/index.test.ts b/tests/index.test.ts index 4ebcd2e54..52a4c20ef 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,10 +1,11 @@ -import { GraphQLClient, rawRequest, request } from '../src' +import graphqlTag from 'graphql-tag' +import { gql, GraphQLClient, rawRequest, request } from '../src' import { setupTestServer } from './__helpers' const ctx = setupTestServer() test('minimal query', async () => { - const data = ctx.mock({ + const { data } = ctx.res({ body: { data: { viewer: { @@ -12,13 +13,13 @@ test('minimal query', async () => { }, }, }, - }).body.data + }).spec.body expect(await request(ctx.url, `{ viewer { id } }`)).toEqual(data) }) test('minimal raw query', async () => { - const { extensions, data } = ctx.mock({ + const { extensions, data } = ctx.res({ body: { data: { viewer: { @@ -29,7 +30,7 @@ test('minimal raw query', async () => { version: '1', }, }, - }).body + }).spec.body const { headers, ...result } = await rawRequest(ctx.url, `{ viewer { id } }`) expect(result).toEqual({ data, extensions, status: 200 }) }) @@ -38,7 +39,7 @@ test('minimal raw query with response headers', async () => { const { headers: reqHeaders, body: { data, extensions }, - } = ctx.mock({ + } = ctx.res({ headers: { 'Content-Type': 'application/json', 'X-Custom-Header': 'test-custom-header', @@ -53,7 +54,7 @@ test('minimal raw query with response headers', async () => { version: '1', }, }, - }) + }).spec const { headers, ...result } = await rawRequest(ctx.url, `{ viewer { id } }`) expect(result).toEqual({ data, extensions, status: 200 }) @@ -61,7 +62,7 @@ test('minimal raw query with response headers', async () => { }) test('content-type with charset', async () => { - const { data } = ctx.mock({ + const { data } = ctx.res({ // headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: { data: { @@ -70,13 +71,13 @@ test('content-type with charset', async () => { }, }, }, - }).body + }).spec.body expect(await request(ctx.url, `{ viewer { id } }`)).toEqual(data) }) test('basic error', async () => { - ctx.mock({ + ctx.res({ body: { errors: { message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n', @@ -88,7 +89,7 @@ test('basic error', async () => { ], }, }, - }).body + }) const res = await request(ctx.url, `x`).catch((x) => x) @@ -98,7 +99,7 @@ test('basic error', async () => { }) test('basic error with raw request', async () => { - ctx.mock({ + ctx.res({ body: { errors: { message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n', @@ -127,7 +128,7 @@ test.skip('extra fetch options', async () => { } const client = new GraphQLClient(ctx.url, options) - const { requests } = ctx.mock({ + const { requests } = ctx.res({ body: { data: { test: 'test' } }, }) await client.request('{ test }') @@ -151,3 +152,56 @@ test.skip('extra fetch options', async () => { ] `) }) + +describe('DocumentNode', () => { + it('accepts graphql DocumentNode as alternative to raw string', async () => { + const mock = ctx.res({ body: { data: { foo: 1 } } }) + await request( + ctx.url, + graphqlTag` + { + query { + users + } + } + ` + ) + expect(mock.requests[0].body).toMatchInlineSnapshot(` + Object { + "query": "{ + query { + users + } + } + ", + } + `) + }) +}) + +describe('gql', () => { + it('passthrough allowing benefits of tooling for gql template tag', async () => { + const mock = ctx.res({ body: { data: { foo: 1 } } }) + await request( + ctx.url, + gql` + { + query { + users + } + } + ` + ) + expect(mock.requests[0].body).toMatchInlineSnapshot(` + Object { + "query": " + { + query { + users + } + } + ", + } + `) + }) +}) diff --git a/yarn.lock b/yarn.lock index 949a1473e..60dcd9e08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2344,6 +2344,16 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +graphql-tag@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.11.0.tgz#1deb53a01c46a7eb401d6cb59dec86fa1cccbffd" + integrity sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA== + +graphql@^15.3.0: + version "15.3.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" + integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== + growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" From 70c57902aca2d7d2843808f758a73b51c4c1c06d Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 3 Aug 2020 10:21:44 -0400 Subject: [PATCH 2/5] import type --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 415368654..08d82e571 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { DocumentNode } from 'graphql/language/ast' +import type { DocumentNode } from 'graphql/language/ast' export type Variables = { [key: string]: any } From bf56370e469e016b93a4872de99c394266629e6d Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 3 Aug 2020 10:41:49 -0400 Subject: [PATCH 3/5] add graphql as a peer dep --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index dd625d623..8893062ac 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "dependencies": { "cross-fetch": "^3.0.4" }, + "peerDependencies": { + "graphql": "14.x || 15.x" + }, "devDependencies": { "@prisma-labs/prettier-config": "^0.1.0", "@types/body-parser": "^1.19.0", From 1a6330f6595383ea5d9d2acc5cca76eedf383153 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 3 Aug 2020 10:44:52 -0400 Subject: [PATCH 4/5] update readme --- README.md | 49 ++++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 370152e9f..6e355d853 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Minimal GraphQL client supporting Node and browsers for scripts or simple apps ## Install ```sh -npm add graphql-request +npm add graphql-request graphql ``` ## Quickstart @@ -22,16 +22,18 @@ npm add graphql-request Send a GraphQL query with a single line of code. ▶️ [Try it out](https://runkit.com/593130bdfad7120012472003/593130bdfad7120012472004). ```js -import { request } from 'graphql-request' - -const query = `{ - Movie(title: "Inception") { - releaseDate - actors { - name +import { request, gql } from 'graphql-request' + +const query = gql` + { + Movie(title: "Inception") { + releaseDate + actors { + name + } } } -}` +` request('https://api.graph.cool/simple/v1/movies', query).then((data) => console.log(data)) ``` @@ -54,7 +56,7 @@ client.request(query, variables).then((data) => console.log(data)) ### Authentication via HTTP header ```js -import { GraphQLClient } from 'graphql-request' +import { GraphQLClient, gql } from 'graphql-request' async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' @@ -65,7 +67,7 @@ async function main() { }, }) - const query = /* GraphQL */ ` + const query = gql` { Movie(title: "Inception") { releaseDate @@ -86,6 +88,7 @@ main().catch((error) => console.error(error)) [TypeScript Source](examples/authentication-via-http-header.ts) #### Dynamically setting headers + If you want to set headers after the GraphQLClient has been initialised, you can use the `setHeader()` or `setHeaders()` functions. ```js @@ -106,7 +109,7 @@ client.setHeaders({ ### Passing more options to fetch ```js -import { GraphQLClient } from 'graphql-request' +import { GraphQLClient, gql } from 'graphql-request' async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' @@ -116,7 +119,7 @@ async function main() { mode: 'cors', }) - const query = /* GraphQL */ ` + const query = gql` { Movie(title: "Inception") { releaseDate @@ -139,12 +142,12 @@ main().catch((error) => console.error(error)) ### Using variables ```js -import { request } from 'graphql-request' +import { request, gql } from 'graphql-request' async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' - const query = /* GraphQL */ ` + const query = gql` query getMovie($title: String!) { Movie(title: $title) { releaseDate @@ -171,12 +174,12 @@ main().catch((error) => console.error(error)) ### Error handling ```js -import { request } from 'graphql-request' +import { request, gql } from 'graphql-request' async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' - const query = /* GraphQL */ ` + const query = gql` { Movie(title: "Inception") { releaseDate @@ -204,12 +207,12 @@ main().catch((error) => console.error(error)) ### Using `require` instead of `import` ```js -const { request } = require('graphql-request') +const { request, gql } = require('graphql-request') async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' - const query = /* GraphQL */ ` + const query = gql` { Movie(title: "Inception") { releaseDate @@ -236,7 +239,7 @@ npm install fetch-cookie ```js require('fetch-cookie/node-fetch')(require('node-fetch')) -import { GraphQLClient } from 'graphql-request' +import { GraphQLClient, gql } from 'graphql-request' async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' @@ -247,7 +250,7 @@ async function main() { }, }) - const query = /* GraphQL */ ` + const query = gql` { Movie(title: "Inception") { releaseDate @@ -273,12 +276,12 @@ The `request` method will return the `data` or `errors` key from the response. If you need to access the `extensions` key you can use the `rawRequest` method: ```js -import { rawRequest } from 'graphql-request' +import { rawRequest, gql } from 'graphql-request' async function main() { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' - const query = /* GraphQL */ ` + const query = gql` { Movie(title: "Inception") { releaseDate From 30ce80a8f0776069b1402a7dea07de55e20cf25a Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Mon, 3 Aug 2020 10:50:57 -0400 Subject: [PATCH 5/5] explain why graphql is required --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6e355d853..f33310876 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,15 @@ main().catch((error) => console.error(error)) ## FAQ -### What's the difference between `graphql-request`, Apollo and Relay? +#### Why do I have to install `graphql`? + +`graphql-request` uses a TypeScript type from the `graphql` package such that if you are using TypeScript to build your project and you are using `graphql-request` but don't have `graphql` installed TypeScript build will fail. Details [here](https://github.com/prisma-labs/graphql-request/pull/183#discussion_r464453076). If you are a JS user then you do not technically need to install `graphql`. However if you use an IDE that picks up TS types even for JS (like VSCode) then its still in your interest to install `graphql` so that you can benefit from enhanced type safety during development. + +#### Do I need to wrap my GraphQL documents inside the `gql` template exported by `graphql-request`? + +No. It is there for convenience so that you can get the tooling support like prettier formatting and IDE syntax highlighting. You can use `gql` from `graphql-tag` if you need it for some reason too. + +#### What's the difference between `graphql-request`, Apollo and Relay? `graphql-request` is the most minimal and simplest to use GraphQL client. It's perfect for small scripts or simple apps.