diff --git a/README.md b/README.md index 370152e9f..f33310876 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 @@ -305,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. diff --git a/package.json b/package.json index 5590f05e1..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", @@ -57,6 +60,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..08d82e571 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { 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"