From a7ac904e3c8bc820d4464d7559036eb9a58a062a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 2 Aug 2022 17:42:33 +0200 Subject: [PATCH] feat: TypedDocumentNode support & strict variable typings (#350) Co-authored-by: Charly POLY Co-authored-by: Jason Kuhrt --- .vscode/settings.json | 2 +- README.md | 69 ++- SECURITY.md | 2 +- examples/custom-fetch.ts | 4 +- examples/passing-custom-header-per-request.ts | 2 +- examples/typed-document-node.ts | 19 + package.json | 1 + src/defaultJsonSerializer.ts | 6 +- src/graphql-ws.ts | 404 +++++++++--------- src/index.ts | 112 +++-- src/parseArgs.ts | 8 +- src/types.ts | 24 +- tests/custom-fetch.test.ts | 22 +- tests/errorPolicy.test.ts | 124 +++--- tests/general.test.ts | 2 +- tests/gql.test.ts | 9 +- tests/graphql-ws.test.ts | 125 +++--- tests/headers.test.ts | 36 +- tests/json-serializer.test.ts | 59 +-- tests/typed-document-node.test.ts | 70 +++ yarn.lock | 5 + 21 files changed, 648 insertions(+), 457 deletions(-) create mode 100644 examples/typed-document-node.ts create mode 100644 tests/typed-document-node.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 3662b3700..25fa6215f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 69d924e34..ee0bf4fa3 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,13 @@ Minimal GraphQL client supporting Node and browsers for scripts or simple apps - - [Features](#features) - [Install](#install) - [Quickstart](#quickstart) - [Usage](#usage) - [Node Version Support](#node-version-support) - [Community](#community) - - [GraphQL Code Generator's GraphQL-Request TypeScript Plugin](#graphql-code-generators-graphql-request-typescript-plugin) + - [GraphQL Code Generator's GraphQL-Request TypeScript Plugin](#graphql-code-generators-graphql-request-typescript-plugin) - [Examples](#examples) - [Authentication via HTTP header](#authentication-via-http-header) - [Incrementally setting headers](#incrementally-setting-headers) @@ -37,9 +36,9 @@ Minimal GraphQL client supporting Node and browsers for scripts or simple apps - [Cancellation](#cancellation) - [Middleware](#middleware) - [FAQ](#faq) - - [Why do I have to install `graphql`?](#why-do-i-have-to-install-graphql) - - [Do I need to wrap my GraphQL documents inside the `gql` template exported by `graphql-request`?](#do-i-need-to-wrap-my-graphql-documents-inside-the-gql-template-exported-by-graphql-request) - - [What's the difference between `graphql-request`, Apollo and Relay?](#whats-the-difference-between-graphql-request-apollo-and-relay) + - [Why do I have to install `graphql`?](#why-do-i-have-to-install-graphql) + - [Do I need to wrap my GraphQL documents inside the `gql` template exported by `graphql-request`?](#do-i-need-to-wrap-my-graphql-documents-inside-the-gql-template-exported-by-graphql-request) + - [What's the difference between `graphql-request`, Apollo and Relay?](#whats-the-difference-between-graphql-request-apollo-and-relay) @@ -167,7 +166,7 @@ client.setHeader('authorization', 'Bearer MY_TOKEN') // Override all existing headers client.setHeaders({ authorization: 'Bearer MY_TOKEN', - anotherheader: 'header_value' + anotherheader: 'header_value', }) ``` @@ -181,14 +180,12 @@ import { GraphQLClient } from 'graphql-request' const client = new GraphQLClient(endpoint) client.setEndpoint(newEndpoint) - ``` #### passing-headers-in-each-request It is possible to pass custom headers for each request. `request()` and `rawRequest()` accept a header object as the third parameter - ```js import { GraphQLClient } from 'graphql-request' @@ -210,7 +207,7 @@ const variables = { } const requestHeaders = { - authorization: 'Bearer MY_TOKEN' + authorization: 'Bearer MY_TOKEN', } // Overrides the clients headers with the passed values @@ -225,11 +222,9 @@ To do that, pass a function that returns the headers to the `headers` property w ```js import { GraphQLClient } from 'graphql-request' -const client = new GraphQLClient(endpoint, - { - headers: () => ({ 'X-Sent-At-Time': Date.now() }) - } -) +const client = new GraphQLClient(endpoint, { + headers: () => ({ 'X-Sent-At-Time': Date.now() }), +}) const query = gql` query getCars { @@ -345,7 +340,7 @@ async function main() { parse: JSON.parse, stringify: JSON.stringify, }, - }); + }) const query = gql` query getMovie($title: String!) { @@ -608,16 +603,15 @@ request('/api/graphql', UploadUserAvatar, { [TypeScript Source](examples/receiving-a-raw-response.ts) - ### Batching It is possible with `graphql-request` to use [batching](https://github.com/graphql/graphql-over-http/blob/main/rfcs/Batching.md) via the `batchRequests()` function. Example available at [examples/batching-requests.ts](examples/batching-requests.ts) ```ts -import { batchRequests } from 'graphql-request'; +import { batchRequests } from 'graphql-request' -(async function () { - const endpoint = 'https://api.spacex.land/graphql/'; +;(async function () { + const endpoint = 'https://api.spacex.land/graphql/' const query1 = /* GraphQL */ ` query ($id: ID!) { @@ -626,7 +620,7 @@ import { batchRequests } from 'graphql-request'; landings } } - `; + ` const query2 = /* GraphQL */ ` { @@ -634,7 +628,7 @@ import { batchRequests } from 'graphql-request'; active } } - `; + ` const data = await batchRequests(endpoint, [ { document: query1, variables: { id: 'C105' } }, @@ -651,42 +645,43 @@ It is possible to cancel a request using an `AbortController` signal. You can define the `signal` in the `GraphQLClient` constructor: ```ts - const abortController = new AbortController() +const abortController = new AbortController() - const client = new GraphQLClient(endpoint, { signal: abortController.signal }) - client.request(query) +const client = new GraphQLClient(endpoint, { signal: abortController.signal }) +client.request(query) - abortController.abort() +abortController.abort() ``` You can also set the signal per request (this will override an existing GraphQLClient signal): ```ts - const abortController = new AbortController() +const abortController = new AbortController() - const client = new GraphQLClient(endpoint) - client.request({ document: query, signal: abortController.signal }) +const client = new GraphQLClient(endpoint) +client.request({ document: query, signal: abortController.signal }) - abortController.abort() +abortController.abort() ``` In Node environment, `AbortController` is supported since version v14.17.0. For Node.js v12 you can use [abort-controller](https://github.com/mysticatea/abort-controller) polyfill. -```` +``` import 'abort-controller/polyfill' const abortController = new AbortController() -```` +``` ### Middleware It's possible to use a middleware to pre-process any request or handle raw response. Request middleware example (set actual auth token to each request): + ```ts function middleware(request: RequestInit) { - const token = getToken(); + const token = getToken() return { ...request, headers: { ...request.headers, 'x-auth-token': token }, @@ -697,12 +692,13 @@ const client = new GraphQLClient(endpoint, { requestMiddleware: middleware }) ``` Response middleware example (log request trace id if error caused): + ```ts function middleware(response: Response) { if (response.errors) { const traceId = response.headers.get('x-b3-traceid') || 'unknown' console.error( - `[${traceId}] Request error: + `[${traceId}] Request error: status ${response.status} details: ${response.errors}` ) @@ -714,20 +710,23 @@ const client = new GraphQLClient(endpoint, { responseMiddleware: middleware }) ### ErrorPolicy -By default GraphQLClient will throw when an error is received. However, sometimes you still want to resolve the (partial) data you received. +By default GraphQLClient will throw when an error is received. However, sometimes you still want to resolve the (partial) data you received. You can define `errorPolicy` in the `GraphQLClient` constructor. ```ts -const client = new GraphQLClient(endpoint, {errorPolicy: "all"}); +const client = new GraphQLClient(endpoint, { errorPolicy: 'all' }) ``` #### None (default) + Allow no errors at all. If you receive a GraphQL error the client will throw. #### Ignore + Ignore incoming errors and resolve like no errors occurred #### All + Return both the errors and data, only works with `rawRequest`. ## FAQ diff --git a/SECURITY.md b/SECURITY.md index 2f55e6d79..5d93b0ad8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,3 @@ # Security Policy -If you have a security issue to report, please contact us at [security@prisma.io](mailto:security@prisma.io). \ No newline at end of file +If you have a security issue to report, please contact us at [security@prisma.io](mailto:security@prisma.io). diff --git a/examples/custom-fetch.ts b/examples/custom-fetch.ts index 6da1b823b..a3d50f8f9 100644 --- a/examples/custom-fetch.ts +++ b/examples/custom-fetch.ts @@ -1,9 +1,9 @@ -import fetch from 'cross-fetch'; +import fetch from 'cross-fetch' import { GraphQLClient } from '../src' ;(async function () { const endpoint = 'https://api.graph.cool/simple/v1/cixos23120m0n0173veiiwrjr' - const graphQLClient = new GraphQLClient(endpoint, { fetch: fetch}) + const graphQLClient = new GraphQLClient(endpoint, { fetch: fetch }) const query = /* GraphQL */ ` { diff --git a/examples/passing-custom-header-per-request.ts b/examples/passing-custom-header-per-request.ts index d4c3433f6..bf37fee7c 100644 --- a/examples/passing-custom-header-per-request.ts +++ b/examples/passing-custom-header-per-request.ts @@ -21,7 +21,7 @@ import { GraphQLClient } from '../src' const requestHeaders = { authorization: 'Bearer MY_TOKEN_2', - 'x-custom': 'foo' + 'x-custom': 'foo', } interface TData { diff --git a/examples/typed-document-node.ts b/examples/typed-document-node.ts new file mode 100644 index 000000000..fd902bf17 --- /dev/null +++ b/examples/typed-document-node.ts @@ -0,0 +1,19 @@ +import { TypedDocumentNode } from '@graphql-typed-document-node/core' +import { parse } from 'graphql' + +import { request } from '../src' +;(async function () { + const endpoint = 'https://graphql-yoga.com/api/graphql' + + const query: TypedDocumentNode<{ greetings: string }, never | Record> = parse(/* GraphQL */ ` + query greetings { + greetings + } + `) + + const variables = {} + + const data = await request(endpoint, query, variables) + + console.log(data.greetings) +})().catch(console.error) diff --git a/package.json b/package.json index 6d4e46a10..329d2d058 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "release:pr": "dripip pr" }, "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", "cross-fetch": "^3.1.5", "extract-files": "^9.0.0", "form-data": "^3.0.0" diff --git a/src/defaultJsonSerializer.ts b/src/defaultJsonSerializer.ts index 09ba3cab9..d8c284a9b 100644 --- a/src/defaultJsonSerializer.ts +++ b/src/defaultJsonSerializer.ts @@ -1,6 +1,6 @@ -import { JsonSerializer } from "./types.dom"; +import { JsonSerializer } from './types.dom' export const defaultJsonSerializer: JsonSerializer = { parse: JSON.parse, - stringify: JSON.stringify -} \ No newline at end of file + stringify: JSON.stringify, +} diff --git a/src/graphql-ws.ts b/src/graphql-ws.ts index b6eaee32a..f4745ae12 100644 --- a/src/graphql-ws.ts +++ b/src/graphql-ws.ts @@ -1,6 +1,6 @@ -import { ClientError, RequestDocument, Variables } from './types'; +import { ClientError, RequestDocument, Variables } from './types' import * as Dom from './types.dom' -import { resolveRequestDocument } from '.'; +import { resolveRequestDocument } from '.' const CONNECTION_INIT = 'connection_init' const CONNECTION_ACK = 'connection_ack' @@ -14,245 +14,267 @@ const COMPLETE = 'complete' type MessagePayload = { [key: string]: any } type SubscribePayload = { - operationName?: string | null; - query: string; - variables?: V; - extensions?: E; + operationName?: string | null + query: string + variables?: V + extensions?: E } class GraphQLWebSocketMessage { - - private _type: string - private _id?: string - private _payload?: A - - public get type(): string { return this._type } - public get id(): string | undefined { return this._id } - public get payload(): A | undefined { return this._payload; } - - constructor(type: string, payload?: A, id?: string) { - this._type = type - this._payload = payload - this._id = id - } - - public get text(): string { - const result: any = { type: this.type } - if (this.id != null && this.id != undefined) result.id = this.id - if (this.payload != null && this.payload != undefined) result.payload = this.payload - return JSON.stringify(result) - } - - static parse(data: string, f: (payload: any) => A): GraphQLWebSocketMessage { - const { type, payload, id }: { type: string, payload: any, id: string } = JSON.parse(data) - return new GraphQLWebSocketMessage(type, f(payload), id) - } + private _type: string + private _id?: string + private _payload?: A + + public get type(): string { + return this._type + } + public get id(): string | undefined { + return this._id + } + public get payload(): A | undefined { + return this._payload + } + + constructor(type: string, payload?: A, id?: string) { + this._type = type + this._payload = payload + this._id = id + } + + public get text(): string { + const result: any = { type: this.type } + if (this.id != null && this.id != undefined) result.id = this.id + if (this.payload != null && this.payload != undefined) result.payload = this.payload + return JSON.stringify(result) + } + + static parse(data: string, f: (payload: any) => A): GraphQLWebSocketMessage { + const { type, payload, id }: { type: string; payload: any; id: string } = JSON.parse(data) + return new GraphQLWebSocketMessage(type, f(payload), id) + } } export type SocketHandler = { - onInit?: () => Promise, - onAcknowledged?: (payload?: A) => Promise, - onPing?: (payload: In) => Promise - onPong?: (payload: T) => any - onClose?: () => any + onInit?: () => Promise + onAcknowledged?: (payload?: A) => Promise + onPing?: (payload: In) => Promise + onPong?: (payload: T) => any + onClose?: () => any } -export type UnsubscribeCallback = () => void; +export type UnsubscribeCallback = () => void -export interface GraphQLSubscriber { - next?(data: T, extensions?: E): void; - error?(errorValue: ClientError): void; - complete?(): void; +export interface GraphQLSubscriber { + next?(data: T, extensions?: E): void + error?(errorValue: ClientError): void + complete?(): void } type SubscriptionRecord = { - subscriber: GraphQLSubscriber - query: string, - variables: Variables + subscriber: GraphQLSubscriber + query: string + variables: Variables } type SocketState = { - acknowledged: boolean - lastRequestId: number - subscriptions: { [key: string]: SubscriptionRecord } + acknowledged: boolean + lastRequestId: number + subscriptions: { [key: string]: SubscriptionRecord } } export class GraphQLWebSocketClient { + static PROTOCOL: string = 'graphql-transport-ws' - static PROTOCOL: string = "graphql-transport-ws" - - private socket: WebSocket - private socketState: SocketState = { acknowledged: false, lastRequestId: 0, subscriptions: {} } - - constructor(socket: WebSocket, { onInit, onAcknowledged, onPing, onPong }: SocketHandler) { - this.socket = socket - - socket.onopen = async (e) => { - this.socketState.acknowledged = false; - this.socketState.subscriptions = {}; - socket.send(ConnectionInit(onInit ? await onInit() : null).text); - }; + private socket: WebSocket + private socketState: SocketState = { acknowledged: false, lastRequestId: 0, subscriptions: {} } - socket.onclose = (e) => { - this.socketState.acknowledged = false; - this.socketState.subscriptions = {}; - }; + constructor(socket: WebSocket, { onInit, onAcknowledged, onPing, onPong }: SocketHandler) { + this.socket = socket - socket.onerror = (e) => { - console.error(e) - } - - socket.onmessage = (e) => { - try { - const message = parseMessage(e.data) - switch (message.type) { - case CONNECTION_ACK: { - if (this.socketState.acknowledged) { - console.warn("Duplicate CONNECTION_ACK message ignored"); - } else { - this.socketState.acknowledged = true - if (onAcknowledged) onAcknowledged(message.payload) - } - return; - } - case PING: { - if (onPing) - onPing(message.payload).then(r => socket.send(Pong(r).text)); - else - socket.send(Pong(null).text); - return; - } - case PONG: { - if (onPong) onPong(message.payload); - return; - } - } - - if (!this.socketState.acknowledged) { - // Web-socket connection not acknowledged - return - } - - if (message.id === undefined || message.id === null || !this.socketState.subscriptions[message.id]) { - // No subscription identifer or subscription indentifier is not found - return - } - const { query, variables, subscriber } = this.socketState.subscriptions[message.id] - - - switch (message.type) { - case NEXT: { - - if (!message.payload.errors && message.payload.data) { - subscriber.next && subscriber.next(message.payload.data); - } - if (message.payload.errors) { - subscriber.error && subscriber.error(new ClientError({ ...message.payload, status: 200 }, { query, variables })); - } else { - } - return; - } - - case ERROR: { - subscriber.error && subscriber.error(new ClientError({ errors: message.payload, status: 200 }, { query, variables })); - return; - } - - case COMPLETE: { - subscriber.complete && subscriber.complete(); - delete this.socketState.subscriptions[message.id] - return; - } - - } - } - catch (e) { - // Unexpected errors while handling graphql-ws message - console.error(e) - socket.close(1006); - } - socket.close(4400, "Unknown graphql-ws message.") - } + socket.onopen = async (e) => { + this.socketState.acknowledged = false + this.socketState.subscriptions = {} + socket.send(ConnectionInit(onInit ? await onInit() : null).text) } - private makeSubscribe(query: string, operationName: string | undefined, variables: V, subscriber: GraphQLSubscriber): UnsubscribeCallback { - - const subscriptionId = (this.socketState.lastRequestId++).toString(); - this.socketState.subscriptions[subscriptionId] = { query, variables, subscriber } - this.socket.send(Subscribe(subscriptionId, { query, operationName, variables }).text); - return () => { - this.socket.send(Complete(subscriptionId).text) - delete this.socketState.subscriptions[subscriptionId] - } + socket.onclose = (e) => { + this.socketState.acknowledged = false + this.socketState.subscriptions = {} } - rawRequest( - query: string, - variables?: V, - ): Promise<{ data: T; extensions?: E }> { - - return new Promise<{ data: T; extensions?: E; headers?: Dom.Headers; status?: number }>((resolve, reject) => { - let result: { data: T; extensions?: E }; - this.rawSubscribe(query, { - next: (data: T, extensions: E) => (result = { data, extensions }), - error: reject, - complete: () => resolve(result), - }, variables); - }); + socket.onerror = (e) => { + console.error(e) } - request(document: RequestDocument, variables?: V): Promise { + socket.onmessage = (e) => { + try { + const message = parseMessage(e.data) + switch (message.type) { + case CONNECTION_ACK: { + if (this.socketState.acknowledged) { + console.warn('Duplicate CONNECTION_ACK message ignored') + } else { + this.socketState.acknowledged = true + if (onAcknowledged) onAcknowledged(message.payload) + } + return + } + case PING: { + if (onPing) onPing(message.payload).then((r) => socket.send(Pong(r).text)) + else socket.send(Pong(null).text) + return + } + case PONG: { + if (onPong) onPong(message.payload) + return + } + } - return new Promise((resolve, reject) => { - let result: T; - this.subscribe(document, { - next: (data: T) => (result = data), - error: reject, - complete: () => resolve(result), - }, variables); - }); - } + if (!this.socketState.acknowledged) { + // Web-socket connection not acknowledged + return + } - subscribe(document: RequestDocument, subscriber: GraphQLSubscriber, variables?: V): UnsubscribeCallback { - const { query, operationName } = resolveRequestDocument(document) - return this.makeSubscribe(query, operationName, variables, subscriber) - } + if (message.id === undefined || message.id === null || !this.socketState.subscriptions[message.id]) { + // No subscription identifer or subscription indentifier is not found + return + } + const { query, variables, subscriber } = this.socketState.subscriptions[message.id] - rawSubscribe(query: string, subscriber: GraphQLSubscriber, variables?: V): UnsubscribeCallback { - return this.makeSubscribe(query, undefined, variables, subscriber) + switch (message.type) { + case NEXT: { + if (!message.payload.errors && message.payload.data) { + subscriber.next && subscriber.next(message.payload.data) + } + if (message.payload.errors) { + subscriber.error && + subscriber.error(new ClientError({ ...message.payload, status: 200 }, { query, variables })) + } else { + } + return + } + + case ERROR: { + subscriber.error && + subscriber.error( + new ClientError({ errors: message.payload, status: 200 }, { query, variables }) + ) + return + } + + case COMPLETE: { + subscriber.complete && subscriber.complete() + delete this.socketState.subscriptions[message.id] + return + } + } + } catch (e) { + // Unexpected errors while handling graphql-ws message + console.error(e) + socket.close(1006) + } + socket.close(4400, 'Unknown graphql-ws message.') } + } - ping(payload: Variables) { - this.socket.send(Ping(payload).text) + private makeSubscribe( + query: string, + operationName: string | undefined, + variables: V, + subscriber: GraphQLSubscriber + ): UnsubscribeCallback { + const subscriptionId = (this.socketState.lastRequestId++).toString() + this.socketState.subscriptions[subscriptionId] = { query, variables, subscriber } + this.socket.send(Subscribe(subscriptionId, { query, operationName, variables }).text) + return () => { + this.socket.send(Complete(subscriptionId).text) + delete this.socketState.subscriptions[subscriptionId] } + } - close() { - this.socket.close(1000); - } + rawRequest( + query: string, + variables?: V + ): Promise<{ data: T; extensions?: E }> { + return new Promise<{ data: T; extensions?: E; headers?: Dom.Headers; status?: number }>( + (resolve, reject) => { + let result: { data: T; extensions?: E } + this.rawSubscribe( + query, + { + next: (data: T, extensions: E) => (result = { data, extensions }), + error: reject, + complete: () => resolve(result), + }, + variables + ) + } + ) + } + + request(document: RequestDocument, variables?: V): Promise { + return new Promise((resolve, reject) => { + let result: T + this.subscribe( + document, + { + next: (data: T) => (result = data), + error: reject, + complete: () => resolve(result), + }, + variables + ) + }) + } + + subscribe( + document: RequestDocument, + subscriber: GraphQLSubscriber, + variables?: V + ): UnsubscribeCallback { + const { query, operationName } = resolveRequestDocument(document) + return this.makeSubscribe(query, operationName, variables, subscriber) + } + + rawSubscribe( + query: string, + subscriber: GraphQLSubscriber, + variables?: V + ): UnsubscribeCallback { + return this.makeSubscribe(query, undefined, variables, subscriber) + } + + ping(payload: Variables) { + this.socket.send(Ping(payload).text) + } + + close() { + this.socket.close(1000) + } } // Helper functions -function parseMessage(data: string, f: (payload: any) => A = a => a): GraphQLWebSocketMessage { - const m = GraphQLWebSocketMessage.parse(data, f) - return m +function parseMessage(data: string, f: (payload: any) => A = (a) => a): GraphQLWebSocketMessage { + const m = GraphQLWebSocketMessage.parse(data, f) + return m } function ConnectionInit(payload?: A) { - return new GraphQLWebSocketMessage(CONNECTION_INIT, payload) + return new GraphQLWebSocketMessage(CONNECTION_INIT, payload) } function Ping(payload: any) { - return new GraphQLWebSocketMessage(PING, payload, undefined) + return new GraphQLWebSocketMessage(PING, payload, undefined) } function Pong(payload: any) { - return new GraphQLWebSocketMessage(PONG, payload, undefined) + return new GraphQLWebSocketMessage(PONG, payload, undefined) } function Subscribe(id: string, payload: SubscribePayload) { - return new GraphQLWebSocketMessage(SUBSCRIBE, payload, id) + return new GraphQLWebSocketMessage(SUBSCRIBE, payload, id) } function Complete(id: string) { - return new GraphQLWebSocketMessage(COMPLETE, undefined, id) + return new GraphQLWebSocketMessage(COMPLETE, undefined, id) } diff --git a/src/index.ts b/src/index.ts index 7129c3e1f..168394367 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import crossFetch, * as CrossFetch from 'cross-fetch' +import { TypedDocumentNode } from '@graphql-typed-document-node/core' import { OperationDefinitionNode, DocumentNode } from 'graphql/language/ast' import { parse } from 'graphql/language/parser' @@ -27,6 +28,7 @@ import { PatchedRequestInit, MaybeFunction, Response, + RemoveIndex, } from './types' import * as Dom from './types.dom' @@ -75,8 +77,18 @@ const resolveHeaders = (headers: Dom.RequestInit['headers']): Record str.replace(/([\s,]|#[^\n\r]+)+/g, ' ').trim() type TBuildGetQueryParams = - | { query: string; variables: V | undefined; operationName: string | undefined; jsonSerializer: Dom.JsonSerializer } - | { query: string[]; variables: V[] | undefined; operationName: undefined; jsonSerializer: Dom.JsonSerializer } + | { + query: string + variables: V | undefined + operationName: string | undefined + jsonSerializer: Dom.JsonSerializer + } + | { + query: string[] + variables: V[] | undefined + operationName: undefined + jsonSerializer: Dom.JsonSerializer + } /** * Create query string for GraphQL request @@ -87,7 +99,12 @@ type TBuildGetQueryParams = * @param {string|undefined} param0.operationName the GraphQL operation name * @param {any|any[]} param0.variables the GraphQL variables to use */ -const buildGetQueryParams = ({ query, variables, operationName, jsonSerializer }: TBuildGetQueryParams): string => { +const buildGetQueryParams = ({ + query, + variables, + operationName, + jsonSerializer, +}: TBuildGetQueryParams): string => { if (!Array.isArray(query)) { const search: string[] = [`query=${encodeURIComponent(queryCleanner(query))}`] @@ -153,7 +170,7 @@ const post = async ({ }, body, ...fetchOptions, - }; + } if (middleware) { options = middleware(options) } @@ -186,14 +203,14 @@ const get = async ({ query, variables, operationName, - jsonSerializer: fetchOptions.jsonSerializer + jsonSerializer: fetchOptions.jsonSerializer, } as TBuildGetQueryParams) let options: Dom.RequestInit = { method: 'GET', headers, ...fetchOptions, - }; + } if (middleware) { options = middleware(options) } @@ -214,9 +231,7 @@ export class GraphQLClient { variables?: V, requestHeaders?: Dom.RequestInit['headers'] ): Promise> - async rawRequest( - options: RawRequestOptions - ): Promise> + async rawRequest(options: RawRequestOptions): Promise> async rawRequest( queryOrOptions: string | RawRequestOptions, variables?: V, @@ -224,7 +239,14 @@ export class GraphQLClient { ): Promise> { const rawRequestOptions = parseRawRequestArgs(queryOrOptions, variables, requestHeaders) - let { headers, fetch = crossFetch, method = 'POST', requestMiddleware, responseMiddleware , ...fetchOptions } = this.options + let { + headers, + fetch = crossFetch, + method = 'POST', + requestMiddleware, + responseMiddleware, + ...fetchOptions + } = this.options let { url } = this if (rawRequestOptions.signal !== undefined) { fetchOptions.signal = rawRequestOptions.signal @@ -245,7 +267,7 @@ export class GraphQLClient { method, fetchOptions, middleware: requestMiddleware, - }).then(response => { + }).then((response) => { if (responseMiddleware) { responseMiddleware(response) } @@ -257,19 +279,33 @@ export class GraphQLClient { * Send a GraphQL document to the server. */ request( - document: RequestDocument, - variables?: V, - requestHeaders?: Dom.RequestInit['headers'] + document: RequestDocument | TypedDocumentNode, + ..._variablesAndRequestHeaders: V extends Record // do we have explicitly no variables allowed? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : keyof RemoveIndex extends never // do we get an empty variables object? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : [variables: V, requestHeaders?: Dom.RequestInit['headers']] ): Promise request(options: RequestOptions): Promise request( - documentOrOptions: RequestDocument | RequestOptions, - variables?: V, - requestHeaders?: Dom.RequestInit['headers'] + documentOrOptions: RequestDocument | TypedDocumentNode | RequestOptions, + ...variablesAndRequestHeaders: V extends Record // do we have explicitly no variables allowed? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : keyof RemoveIndex extends never // do we get an empty variables object? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : [variables: V, requestHeaders?: Dom.RequestInit['headers']] ): Promise { + const [variables, requestHeaders] = variablesAndRequestHeaders const requestOptions = parseRequestArgs(documentOrOptions, variables, requestHeaders) - let { headers, fetch = crossFetch, method = 'POST', requestMiddleware, responseMiddleware, ...fetchOptions } = this.options + let { + headers, + fetch = crossFetch, + method = 'POST', + requestMiddleware, + responseMiddleware, + ...fetchOptions + } = this.options let { url } = this if (requestOptions.signal !== undefined) { fetchOptions.signal = requestOptions.signal @@ -290,7 +326,7 @@ export class GraphQLClient { method, fetchOptions, middleware: requestMiddleware, - }).then(response => { + }).then((response) => { if (responseMiddleware) { responseMiddleware(response) } @@ -312,7 +348,14 @@ export class GraphQLClient { ): Promise { const batchRequestOptions = parseBatchRequestArgs(documentsOrOptions, requestHeaders) - let { headers, fetch = crossFetch, method = 'POST', requestMiddleware, responseMiddleware, ...fetchOptions } = this.options + let { + headers, + fetch = crossFetch, + method = 'POST', + requestMiddleware, + responseMiddleware, + ...fetchOptions + } = this.options let { url } = this if (batchRequestOptions.signal !== undefined) { fetchOptions.signal = batchRequestOptions.signal @@ -336,7 +379,7 @@ export class GraphQLClient { method, fetchOptions, middleware: requestMiddleware, - }).then(response => { + }).then((response) => { if (responseMiddleware) { responseMiddleware(response) } @@ -422,7 +465,7 @@ async function makeRequest({ const { errors, ...rest } = result const data = fetchOptions.errorPolicy === 'ignore' ? rest : result - + return { ...(isBathchingQuery ? { data } : data), headers, @@ -498,17 +541,24 @@ export async function rawRequest( */ export async function request( url: string, - document: RequestDocument, - variables?: V, - requestHeaders?: Dom.RequestInit['headers'] + document: RequestDocument | TypedDocumentNode, + ..._variablesAndRequestHeaders: V extends Record // do we have explicitly no variables allowed? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : keyof RemoveIndex extends never // do we get an empty variables object? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : [variables: V, requestHeaders?: Dom.RequestInit['headers']] ): Promise -export async function request(options: RequestExtendedOptions): Promise +export async function request(options: RequestExtendedOptions): Promise export async function request( - urlOrOptions: string | RequestExtendedOptions, - document?: RequestDocument, - variables?: V, - requestHeaders?: Dom.RequestInit['headers'] + urlOrOptions: string | RequestExtendedOptions, + document?: RequestDocument | TypedDocumentNode, + ...variablesAndRequestHeaders: V extends Record // do we have explicitly no variables allowed? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : keyof RemoveIndex extends never // do we get an empty variables object? + ? [variables?: V, requestHeaders?: Dom.RequestInit['headers']] + : [variables: V, requestHeaders?: Dom.RequestInit['headers']] ): Promise { + const [variables, requestHeaders] = variablesAndRequestHeaders const requestOptions = parseRequestExtendedArgs(urlOrOptions, document, variables, requestHeaders) const client = new GraphQLClient(requestOptions.url) return client.request({ @@ -626,7 +676,7 @@ export function resolveRequestDocument(document: RequestDocument): { query: stri } function callOrIdentity(value: MaybeFunction) { - return typeof value === 'function' ? (value as () => T)() : value; + return typeof value === 'function' ? (value as () => T)() : value } /** diff --git a/src/parseArgs.ts b/src/parseArgs.ts index 3e037c6d8..765a6bd2d 100644 --- a/src/parseArgs.ts +++ b/src/parseArgs.ts @@ -18,12 +18,12 @@ export function parseRequestArgs( ): RequestOptions { return (documentOrOptions as RequestOptions).document ? (documentOrOptions as RequestOptions) - : { + : ({ document: documentOrOptions as RequestDocument, variables: variables, requestHeaders: requestHeaders, signal: undefined, - } + } as unknown as RequestOptions) } export function parseRawRequestArgs( @@ -62,13 +62,13 @@ export function parseRequestExtendedArgs( ): RequestExtendedOptions { return (urlOrOptions as RequestExtendedOptions).document ? (urlOrOptions as RequestExtendedOptions) - : { + : ({ url: urlOrOptions as string, document: document as RequestDocument, variables: variables, requestHeaders: requestHeaders, signal: undefined, - } + } as unknown as RequestExtendedOptions) } export function parseRawRequestExtendedArgs( diff --git a/src/types.ts b/src/types.ts index efb04d87e..ee4a686e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import { TypedDocumentNode } from '@graphql-typed-document-node/core' import { DocumentNode } from 'graphql/language/ast' import type { GraphQLError } from 'graphql/error/GraphQLError' import * as Dom from './types.dom' @@ -6,6 +7,10 @@ export type { GraphQLError } export type Variables = { [key: string]: any } +export type RemoveIndex = { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] +} + export interface GraphQLResponse { data?: T errors?: GraphQLError[] @@ -51,7 +56,7 @@ export class ClientError extends Error { } } -export type MaybeFunction = T | (() => T); +export type MaybeFunction = T | (() => T) export type RequestDocument = string | DocumentNode @@ -63,11 +68,11 @@ export interface Response { status: number } -export type PatchedRequestInit = Omit & { +export type PatchedRequestInit = Omit & { headers?: MaybeFunction requestMiddleware?: (request: Dom.RequestInit) => Dom.RequestInit responseMiddleware?: (response: Response) => void -}; +} export type BatchRequestDocument = { document: RequestDocument @@ -81,12 +86,15 @@ export type RawRequestOptions = { signal?: Dom.RequestInit['signal'] } -export type RequestOptions = { - document: RequestDocument - variables?: V +export type RequestOptions = { + document: RequestDocument | TypedDocumentNode requestHeaders?: Dom.RequestInit['headers'] signal?: Dom.RequestInit['signal'] -} +} & (V extends Record + ? { variables?: V } + : keyof RemoveIndex extends never + ? { variables?: V } + : { variables: V }) export type BatchRequestsOptions = { documents: BatchRequestDocument[] @@ -94,7 +102,7 @@ export type BatchRequestsOptions = { signal?: Dom.RequestInit['signal'] } -export type RequestExtendedOptions = { url: string } & RequestOptions +export type RequestExtendedOptions = { url: string } & RequestOptions export type RawRequestExtendedOptions = { url: string } & RawRequestOptions diff --git a/tests/custom-fetch.test.ts b/tests/custom-fetch.test.ts index 8025dc8eb..7a4b866ff 100644 --- a/tests/custom-fetch.test.ts +++ b/tests/custom-fetch.test.ts @@ -1,18 +1,18 @@ import { GraphQLClient } from '../src' import { setupTestServer } from './__helpers' -import fetch from 'cross-fetch'; +import fetch from 'cross-fetch' const ctx = setupTestServer() test('with custom fetch', async () => { - let touched = false; - // wrap fetch in a custom method - const customFetch = function(input: RequestInfo, init?: RequestInit) { - touched = true - return fetch(input, init) - } - const client = new GraphQLClient(ctx.url, { fetch: customFetch }) - const mock = ctx.res() - await client.request(`{ me { id } }`) - expect(touched).toEqual(true) + let touched = false + // wrap fetch in a custom method + const customFetch = function (input: RequestInfo, init?: RequestInit) { + touched = true + return fetch(input, init) + } + const client = new GraphQLClient(ctx.url, { fetch: customFetch }) + const mock = ctx.res() + await client.request(`{ me { id } }`) + expect(touched).toEqual(true) }) diff --git a/tests/errorPolicy.test.ts b/tests/errorPolicy.test.ts index 177d3a9a4..25748d7dc 100644 --- a/tests/errorPolicy.test.ts +++ b/tests/errorPolicy.test.ts @@ -1,62 +1,62 @@ -import { GraphQLClient } from '../src' -import { setupTestServer } from './__helpers' - -const ctx = setupTestServer() -const errors = { - message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n', - locations: [ - { - line: 1, - column: 1, - }, - ], -} - -test('should throw error when error policy not set', async () => { - ctx.res({ - body: { - data: {}, - errors, - }, - }) - - expect(async () => await new GraphQLClient(ctx.url).rawRequest(`x`)).rejects.toThrow('GraphQL Error') -}) - -test('should throw error when error policy set to "none"', async () => { - ctx.res({ - body: { - data: {}, - errors, - }, - }) - - expect(async () => await new GraphQLClient(ctx.url).rawRequest(`x`)).rejects.toThrow('GraphQL Error') -}) - -test('should not throw error when error policy set to "ignore" and return only data', async () => { - ctx.res({ - body: { - data: { test: {} }, - errors, - }, - }) - - const res = await new GraphQLClient(ctx.url, { errorPolicy: 'ignore' }).rawRequest(`x`) - - expect(res).toEqual(expect.objectContaining({ data: { test: {} } })) - expect(res).toEqual(expect.not.objectContaining({ errors })) -}) - -test('should not throw error when error policy set to "all" and return both data and error', async () => { - ctx.res({ - body: { - data: { test: {} }, - errors, - }, - }) - - const res = await new GraphQLClient(ctx.url, { errorPolicy: 'all' }).rawRequest(`x`) - - expect(res).toEqual(expect.objectContaining({ data: { test: {} }, errors })) -}) +import { GraphQLClient } from '../src' +import { setupTestServer } from './__helpers' + +const ctx = setupTestServer() +const errors = { + message: 'Syntax Error GraphQL request (1:1) Unexpected Name "x"\n\n1: x\n ^\n', + locations: [ + { + line: 1, + column: 1, + }, + ], +} + +test('should throw error when error policy not set', async () => { + ctx.res({ + body: { + data: {}, + errors, + }, + }) + + expect(async () => await new GraphQLClient(ctx.url).rawRequest(`x`)).rejects.toThrow('GraphQL Error') +}) + +test('should throw error when error policy set to "none"', async () => { + ctx.res({ + body: { + data: {}, + errors, + }, + }) + + expect(async () => await new GraphQLClient(ctx.url).rawRequest(`x`)).rejects.toThrow('GraphQL Error') +}) + +test('should not throw error when error policy set to "ignore" and return only data', async () => { + ctx.res({ + body: { + data: { test: {} }, + errors, + }, + }) + + const res = await new GraphQLClient(ctx.url, { errorPolicy: 'ignore' }).rawRequest(`x`) + + expect(res).toEqual(expect.objectContaining({ data: { test: {} } })) + expect(res).toEqual(expect.not.objectContaining({ errors })) +}) + +test('should not throw error when error policy set to "all" and return both data and error', async () => { + ctx.res({ + body: { + data: { test: {} }, + errors, + }, + }) + + const res = await new GraphQLClient(ctx.url, { errorPolicy: 'all' }).rawRequest(`x`) + + expect(res).toEqual(expect.objectContaining({ data: { test: {} }, errors })) +}) diff --git a/tests/general.test.ts b/tests/general.test.ts index 097d7e901..44e92d973 100644 --- a/tests/general.test.ts +++ b/tests/general.test.ts @@ -131,7 +131,7 @@ describe('middleware', () => { }, }) - requestMiddleware = jest.fn(req => ({ ...req })) + requestMiddleware = jest.fn((req) => ({ ...req })) responseMiddleware = jest.fn() client = new GraphQLClient(ctx.url, { requestMiddleware, responseMiddleware }) }) diff --git a/tests/gql.test.ts b/tests/gql.test.ts index 6d8252e87..1b37da957 100644 --- a/tests/gql.test.ts +++ b/tests/gql.test.ts @@ -9,10 +9,11 @@ describe('gql', () => { const mock = ctx.res({ body: { data: { foo: 1 } } }) await request( ctx.url, - gql`query allUsers { - users -} -` + gql` + query allUsers { + users + } + ` ) expect(mock).toMatchSnapshot() }) diff --git a/tests/graphql-ws.test.ts b/tests/graphql-ws.test.ts index 0b1cf2b51..e56d35256 100644 --- a/tests/graphql-ws.test.ts +++ b/tests/graphql-ws.test.ts @@ -1,77 +1,90 @@ -import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql'; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from 'graphql' import gql from 'graphql-tag' -import { useServer } from 'graphql-ws/lib/use/ws'; -import { GRAPHQL_TRANSPORT_WS_PROTOCOL } from 'graphql-ws'; -import { GraphQLWebSocketClient } from '../src/graphql-ws'; -import getPort from 'get-port'; -import WebSocketImpl, { Server as WebSocketServer } from 'ws'; +import { useServer } from 'graphql-ws/lib/use/ws' +import { GRAPHQL_TRANSPORT_WS_PROTOCOL } from 'graphql-ws' +import { GraphQLWebSocketClient } from '../src/graphql-ws' +import getPort from 'get-port' +import WebSocketImpl, { Server as WebSocketServer } from 'ws' async function createClient(url: string) { - return new Promise((resolve) => { - const socket = new WebSocketImpl(url, GRAPHQL_TRANSPORT_WS_PROTOCOL); - const client: GraphQLWebSocketClient = new GraphQLWebSocketClient((socket as unknown) as WebSocket, { onAcknowledged: async (_p) => resolve(client) }) + return new Promise((resolve) => { + const socket = new WebSocketImpl(url, GRAPHQL_TRANSPORT_WS_PROTOCOL) + const client: GraphQLWebSocketClient = new GraphQLWebSocketClient(socket as unknown as WebSocket, { + onAcknowledged: async (_p) => resolve(client), }) + }) } const schema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { - hello: { - type: GraphQLString, - resolve: () => 'world', - }, + query: new GraphQLObjectType({ + name: 'Query', + fields: { + hello: { + type: GraphQLString, + resolve: () => 'world', + }, + }, + }), + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + greetings: { + type: GraphQLString, + subscribe: async function* () { + for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { + yield { greetings: hi } + } }, - }), - subscription: new GraphQLObjectType({ - name: 'Subscription', - fields: { - greetings: { - type: GraphQLString, - subscribe: async function* () { - for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { - yield { greetings: hi }; - } - }, - }, - }, - }), -}); - + }, + }, + }), +}) -var ctx: { server: WebSocketServer, url: string } +var ctx: { server: WebSocketServer; url: string } beforeAll(async () => { - const port = await getPort() - const server = new WebSocketServer({ path: '/graphql', host: '127.0.0.1', port }); - useServer({ schema }, server); - ctx = { server, url: `ws://localhost:${port}/graphql` } + const port = await getPort() + const server = new WebSocketServer({ path: '/graphql', host: '127.0.0.1', port }) + useServer({ schema }, server) + ctx = { server, url: `ws://localhost:${port}/graphql` } }) afterAll(() => { - ctx.server.close() + ctx.server.close() }) test('graphql-ws request', async () => { - const client = await createClient(ctx.url) - const data = client.request( - gql`query hello { hello }` - ) - expect(await data).toEqual({ hello: "world" }) - client.close(); + const client = await createClient(ctx.url) + const data = client.request( + gql` + query hello { + hello + } + ` + ) + expect(await data).toEqual({ hello: 'world' }) + client.close() }) test('graphql-ws subscription', async () => { - const client = await createClient(ctx.url) - const result = new Promise((resolve) => { - var allGreatings = ''; - client.subscribe<{ greetings: string }>( - gql`subscription greetings { greetings }`, - { - next: ({ greetings }) => allGreatings = allGreatings != '' ? `${allGreatings},${greetings}` : greetings, - complete: () => { resolve(allGreatings) } - }) - }) - expect(await result).toEqual("Hi,Bonjour,Hola,Ciao,Zdravo") - client.close(); + const client = await createClient(ctx.url) + const result = new Promise((resolve) => { + var allGreatings = '' + client.subscribe<{ greetings: string }>( + gql` + subscription greetings { + greetings + } + `, + { + next: ({ greetings }) => + (allGreatings = allGreatings != '' ? `${allGreatings},${greetings}` : greetings), + complete: () => { + resolve(allGreatings) + }, + } + ) + }) + expect(await result).toEqual('Hi,Bonjour,Hola,Ciao,Zdravo') + client.close() }) diff --git a/tests/headers.test.ts b/tests/headers.test.ts index 0837b061b..981eb8e9f 100644 --- a/tests/headers.test.ts +++ b/tests/headers.test.ts @@ -44,16 +44,15 @@ describe('using class', () => { describe.each([ [new H({ 'x-request-foo': 'request-bar' })], [{ 'x-request-foo': 'request-bar' }], - [[['x-request-foo', 'request-bar']]] + [[['x-request-foo', 'request-bar']]], ])('request unique header with request', (headerCase: Dom.RequestInit['headers']) => { - test('with request method', async () => { const client = new GraphQLClient(ctx.url) client.setHeaders(new H({ 'x-foo': 'bar' })) const mock = ctx.res() await client.request(`{ me { id } }`, {}, headerCase) - + expect(mock.requests[0].headers['x-foo']).toEqual('bar') expect(mock.requests[0].headers['x-request-foo']).toEqual('request-bar') }) @@ -64,51 +63,50 @@ describe('using class', () => { client.setHeaders(new H({ 'x-foo': 'bar' })) const mock = ctx.res() await client.rawRequest(`{ me { id } }`, {}, headerCase) - + expect(mock.requests[0].headers['x-foo']).toEqual('bar') expect(mock.requests[0].headers['x-request-foo']).toEqual('request-bar') }) }) - + describe.each([ [new H({ 'x-foo': 'request-bar' })], [{ 'x-foo': 'request-bar' }], - [[['x-foo', 'request-bar']]] + [[['x-foo', 'request-bar']]], ])('request header overriding the client header', (headerCase: Dom.RequestInit['headers']) => { test('with request method', async () => { const client = new GraphQLClient(ctx.url) client.setHeader('x-foo', 'bar') const mock = ctx.res() - await client.request(`{ me { id } }`, {}, headerCase); + await client.request(`{ me { id } }`, {}, headerCase) expect(mock.requests[0].headers['x-foo']).toEqual('request-bar') - }); + }) test('with rawRequest method', async () => { const client = new GraphQLClient(ctx.url) client.setHeader('x-foo', 'bar') const mock = ctx.res() - await client.rawRequest(`{ me { id } }`, {}, headerCase); + await client.rawRequest(`{ me { id } }`, {}, headerCase) expect(mock.requests[0].headers['x-foo']).toEqual('request-bar') - }); - + }) }) describe('gets fresh dynamic headers before each request', () => { test('with request method', async () => { const objectChangedThroughReference = { 'x-foo': 'old' } - const client = new GraphQLClient(ctx.url, { headers: () => objectChangedThroughReference }); - objectChangedThroughReference['x-foo'] = 'new'; + const client = new GraphQLClient(ctx.url, { headers: () => objectChangedThroughReference }) + objectChangedThroughReference['x-foo'] = 'new' const mock = ctx.res() - await client.request(`{ me { id } }`); + await client.request(`{ me { id } }`) expect(mock.requests[0].headers['x-foo']).toEqual('new') }) test('with rawRequest method', async () => { const objectChangedThroughReference = { 'x-foo': 'old' } - const client = new GraphQLClient(ctx.url, { headers: () => objectChangedThroughReference }); - objectChangedThroughReference['x-foo'] = 'new'; + const client = new GraphQLClient(ctx.url, { headers: () => objectChangedThroughReference }) + objectChangedThroughReference['x-foo'] = 'new' const mock = ctx.res() - await client.rawRequest(`{ me { id } }`); + await client.rawRequest(`{ me { id } }`) expect(mock.requests[0].headers['x-foo']).toEqual('new') }) }) @@ -119,13 +117,13 @@ describe('using request function', () => { describe.each([ [new H({ 'x-request-foo': 'request-bar' })], [{ 'x-request-foo': 'request-bar' }], - [[['x-request-foo', 'request-bar']]] + [[['x-request-foo', 'request-bar']]], ])('request unique header with request', (headerCase: Dom.RequestInit['headers']) => { test('sets header', async () => { const mock = ctx.res() await request(ctx.url, `{ me { id } }`, {}, headerCase) expect(mock.requests[0].headers['x-request-foo']).toEqual('request-bar') - }); + }) }) }) diff --git a/tests/json-serializer.test.ts b/tests/json-serializer.test.ts index fa1ea4bba..18b243d80 100644 --- a/tests/json-serializer.test.ts +++ b/tests/json-serializer.test.ts @@ -7,30 +7,31 @@ import * as Dom from '../src/types.dom' const ctx = setupTestServer() describe('jsonSerializer option', () => { - let serializer: Dom.JsonSerializer; + let serializer: Dom.JsonSerializer const testData = { data: { test: { name: 'test' } } } - let fetch: any; + let fetch: any beforeEach(() => { serializer = { stringify: jest.fn(JSON.stringify), - parse: jest.fn(JSON.parse) + parse: jest.fn(JSON.parse), } - fetch = (url: string) => Promise.resolve({ - headers: new Map([['Content-Type', 'application/json; charset=utf-8']]), - data: testData, - text: function () { - return JSON.stringify(testData) - }, - ok: true, - status: 200, - url, - }); + fetch = (url: string) => + Promise.resolve({ + headers: new Map([['Content-Type', 'application/json; charset=utf-8']]), + data: testData, + text: function () { + return JSON.stringify(testData) + }, + ok: true, + status: 200, + url, + }) }) - + test('is used for parsing response body', async () => { - const options: Dom.RequestInit = { jsonSerializer: serializer, fetch }; - const client: GraphQLClient = new GraphQLClient(ctx.url, options); + const options: Dom.RequestInit = { jsonSerializer: serializer, fetch } + const client: GraphQLClient = new GraphQLClient(ctx.url, options) const result = await client.request('{ test { name } }') expect(result).toEqual(testData.data) @@ -44,15 +45,19 @@ describe('jsonSerializer option', () => { let options: Dom.RequestInit let client: GraphQLClient - const testSingleQuery = (expectedNumStringifyCalls = 1, variables: any = simpleVariable) => async () => { - await client.request(document, variables) - expect(serializer.stringify).toBeCalledTimes(expectedNumStringifyCalls) - } + const testSingleQuery = + (expectedNumStringifyCalls = 1, variables: any = simpleVariable) => + async () => { + await client.request(document, variables) + expect(serializer.stringify).toBeCalledTimes(expectedNumStringifyCalls) + } - const testBatchQuery = (expectedNumStringifyCalls: number, variables: any = simpleVariable) => async () => { - await client.batchRequests([{document, variables}]) - expect(serializer.stringify).toBeCalledTimes(expectedNumStringifyCalls) - } + const testBatchQuery = + (expectedNumStringifyCalls: number, variables: any = simpleVariable) => + async () => { + await client.batchRequests([{ document, variables }]) + expect(serializer.stringify).toBeCalledTimes(expectedNumStringifyCalls) + } describe('request body', () => { beforeEach(() => { @@ -69,8 +74,8 @@ describe('jsonSerializer option', () => { const fileName = 'upload.test.ts' const file = createReadStream(join(__dirname, fileName)) - test('single query', testSingleQuery(2, {...simpleVariable, file})) - test('batch query', testBatchQuery(2, {...simpleVariable, file})) + test('single query', testSingleQuery(2, { ...simpleVariable, file })) + test('batch query', testBatchQuery(2, { ...simpleVariable, file })) }) }) @@ -79,7 +84,7 @@ describe('jsonSerializer option', () => { options = { jsonSerializer: serializer, fetch, method: 'GET' } client = new GraphQLClient(ctx.url, options) }) - + test('single query', testSingleQuery()) test('batch query', testBatchQuery(2)) // once for variable and once for query batch array }) diff --git a/tests/typed-document-node.test.ts b/tests/typed-document-node.test.ts new file mode 100644 index 000000000..c62ebf08f --- /dev/null +++ b/tests/typed-document-node.test.ts @@ -0,0 +1,70 @@ +import { TypedDocumentNode } from '@graphql-typed-document-node/core' +import { parse } from 'graphql' +import request from '../src' +import { setupTestServer } from './__helpers' + +const ctx = setupTestServer() + +test('typed-document-node code should TS compile', async () => { + ctx.res({ body: { data: { foo: 1 } } }) + + const query: TypedDocumentNode<{ echo: string }, { str: string }> = parse(/* GraphQL */ ` + query greetings($str: String!) { + echo(str: $echo) + } + `) + + // variables are mandatory here! + + // @ts-expect-error 'str' is declared here. + await request(ctx.url, query, {}) + // @ts-expect-error Arguments for the rest parameter '_variablesAndRequestHeaders' were not provided. + await request(ctx.url, query) + + await request(ctx.url, query, { str: 'Hi' }) + + const document: TypedDocumentNode<{ echo: string }, { str: string }> = parse(/* GraphQL */ ` + query greetings($str: String!) { + echo(str: $echo) + } + `) + + // variables are mandatory here! + + // @ts-expect-error 'variables' is declared here. + await request({ + url: ctx.url, + document, + }) + + await request({ + url: ctx.url, + document, + // @ts-expect-error Property 'str' is missing in type '{}' but required in type '{ str: string; }'. + variables: {}, + }) + + await request({ + url: ctx.url, + document, + // @ts-expect-error Type '{ aaa: string; }' is not assignable to type '{ str: string; }'. + variables: { aaa: 'aaa' }, + }) + + await request({ + url: ctx.url, + document, + // @ts-expect-error Type 'number' is not assignable to type 'string'.ts(2322) + variables: { str: 1 }, + }) + + await request({ + url: ctx.url, + document, + variables: { + str: 'foo', + }, + }) + + expect(1).toBe(1) +}) diff --git a/yarn.lock b/yarn.lock index 40b4ded5e..db46dd1c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -394,6 +394,11 @@ dependencies: tslib "~2.3.0" +"@graphql-typed-document-node/core@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" + integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"